summaryrefslogtreecommitdiff
path: root/lib/mix/lib/mix/tasks/test.coverage.ex
blob: ff8c67306a33e9cd902e0912751d535da0be24c9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
defmodule Mix.Tasks.Test.Coverage do
  use Mix.Task

  @moduledoc """
  Build reports from exported test coverage.

  In this moduledoc, we will describe how the default test
  coverage works in Elixir and also explore how it is capable
  of export coverage results to group reports from multiple
  test runs.

  ## Line coverage

  Elixir uses Erlang's [`:cover`](https://www.erlang.org/doc/man/cover.html)
  for its default test coverage. Erlang coverage is done by tracking
  *executable lines of code*. This implies blank lines, code comments,
  function signatures, and patterns are not necessarily executable and
  therefore won't be tracked ín coverage reports. Code in macros are
  also often executed at compilation time, and therefore may not covered.
  Similarly, Elixir AST literals, such as atoms, are not executable either.

  Let's see an example:

      if some_condition? do
        do_this()
      else
        do_that()
      end

  In the example above, if your tests exercise both `some_condition? == true`
  and `some_condition? == false`, all branches will be covered, as they all
  have executable code. However, the following code

      if some_condition? do
        do_this()
      else
        :default
      end

  won't ever mark the `:default` branch as covered, as there is no executable
  code in the `else` branch. Note, however, this issue does not happen on `case`
  or `cond`, as Elixir is able to mark the clause operator `->` as executable in
  such corner cases:

      case some_condition? do
        true ->
          do_this()

        false ->
          :default
      end

  If the code above is tested with both conditions, you should see entries
  in both branches marked as covered.

  Finally, it is worth discussing that line coverage by itself has its own
  limitations. For example, take the following code:

      do_this() || do_that()

  Line coverage is not capable of expressing that both `do_this()` and
  `do_that()` have been executed, since as soon as `do_this()` is executed,
  the whole line is covered. Other techniques, such as branch coverage,
  can help spot those cases, but they are not currently supported by the
  default coverage tool.

  Overall, code coverage can be a great tool for finding out flaws in our
  code (such as functions that haven't been covered) but it can also lead
  teams into a false sense of security since 100% coverage never means all
  different executions flows have been asserted, even with the most advanced
  coverage techniques. It is up to you and your team to specify how much
  emphasis you want to place on it.

  ## Exporting coverage

  This task can be used when you need to group the coverage
  across multiple test runs. Let's see some examples.

  ### Example: aggregating partitioned runs

  If you partition your tests across multiple runs,
  you can unify the report as shown below:

      MIX_TEST_PARTITION=1 mix test --partitions 2 --cover
      MIX_TEST_PARTITION=2 mix test --partitions 2 --cover
      mix test.coverage

  This works because the `--partitions` option
  automatically exports the coverage results.

  ### Example: aggregating coverage reports from all umbrella children

  If you run `mix test.coverage` inside an umbrella,
  it will automatically gather exported cover results
  from all umbrella children - as long as the coverage
  results have been exported, like this:

      # from the umbrella root
      mix test --cover --export-coverage default
      mix test.coverage

  Of course, if you want to actually partition the tests,
  you can also do:

      # from the umbrella root
      MIX_TEST_PARTITION=1 mix test --partitions 2 --cover
      MIX_TEST_PARTITION=2 mix test --partitions 2 --cover
      mix test.coverage

  On the other hand, if you want partitioned tests but
  per-app reports, you can do:

      # from the umbrella root
      MIX_TEST_PARTITION=1 mix test --partitions 2 --cover
      MIX_TEST_PARTITION=2 mix test --partitions 2 --cover
      mix cmd mix test.coverage

  When running `test.coverage` from the umbrella root, it
  will use the `:test_coverage` configuration from the umbrella
  root.

  Finally, note the coverage itself is not measured across
  the projects themselves. For example, if project B depends
  on A, and if there is code in A that is only executed from
  project B, those lines will not be marked as covered, which
  is important, as those projects should be developed and tested
  in isolation.

  ### Other scenarios

  There may be other scenarios where you want to export coverage.
  For example, you may have broken your test suite into two, one
  for unit tests and another for integration tests. In such scenarios,
  you can explicitly use the `--export-coverage` command line option,
  or the `:export` option under `:test_coverage` in your `mix.exs` file.
  """

  @shortdoc "Build report from exported test coverage"
  @preferred_cli_env :test
  @default_threshold 90

  @doc false
  def run(_args) do
    Mix.Task.run("compile")
    config = Mix.Project.config()
    test_coverage = config[:test_coverage] || []
    {cover_paths, compile_paths} = apps_paths(config, test_coverage)
    pid = cover_compile(compile_paths)

    case Enum.flat_map(cover_paths, &Path.wildcard(Path.join(&1, "*.coverdata"))) do
      [] ->
        Mix.shell().error(
          "Could not find .coverdata file in any of the paths: " <>
            Enum.join(cover_paths, ", ")
        )

      entries ->
        for entry <- entries do
          Mix.shell().info("Importing cover results: #{entry}")
          :ok = :cover.import(String.to_charlist(entry))
        end

        # Silence analyse import messages emitted by cover
        {:ok, string_io} = StringIO.open("")
        Process.group_leader(pid, string_io)
        Mix.shell().info("")
        generate_cover_results(test_coverage)
    end
  end

  defp apps_paths(config, test_coverage) do
    output = Keyword.get(test_coverage, :output, "cover")

    if apps_paths = Mix.Project.apps_paths(config) do
      build_path = Mix.Project.build_path(config)

      compile_paths =
        Enum.map(apps_paths, fn {app, _} ->
          Path.join([build_path, "lib", Atom.to_string(app), "ebin"])
        end)

      {Enum.map(apps_paths, fn {_, path} -> Path.join(path, output) end), compile_paths}
    else
      {[output], [Mix.Project.compile_path(config)]}
    end
  end

  @doc false
  def start(compile_path, opts) do
    Mix.shell().info("Cover compiling modules ...")

    if Keyword.get(opts, :local_only, true) do
      :cover.local_only()
    end

    cover_compile([compile_path])

    if name = opts[:export] do
      fn ->
        Mix.shell().info("\nExporting cover results ...\n")
        export_cover_results(name, opts)
      end
    else
      fn ->
        Mix.shell().info("\nGenerating cover results ...\n")
        generate_cover_results(opts)
      end
    end
  end

  defp cover_compile(compile_paths) do
    _ = :cover.stop()
    {:ok, pid} = :cover.start()

    for compile_path <- compile_paths do
      case :cover.compile_beam(beams(compile_path)) do
        results when is_list(results) ->
          :ok

        {:error, reason} ->
          Mix.raise(
            "Failed to cover compile directory #{inspect(Path.relative_to_cwd(compile_path))} " <>
              "with reason: #{inspect(reason)}"
          )
      end
    end

    pid
  end

  # Pick beams from the compile_path but if by any chance it is a protocol,
  # gets its path from the code server (which will most likely point to
  # the consolidation directory as long as it is enabled).
  defp beams(dir) do
    consolidation_dir = Mix.Project.consolidation_path()

    consolidated =
      case File.ls(consolidation_dir) do
        {:ok, files} -> files
        _ -> []
      end

    for file <- File.ls!(dir), Path.extname(file) == ".beam" do
      with true <- file in consolidated,
           [_ | _] = path <- :code.which(file |> Path.rootname() |> String.to_atom()) do
        path
      else
        _ -> String.to_charlist(Path.join(dir, file))
      end
    end
  end

  defp export_cover_results(name, opts) do
    output = Keyword.get(opts, :output, "cover")
    File.mkdir_p!(output)

    case :cover.export('#{output}/#{name}.coverdata') do
      :ok ->
        Mix.shell().info("Run \"mix test.coverage\" once all exports complete")

      {:error, reason} ->
        Mix.shell().error("Export failed with reason: #{inspect(reason)}")
    end
  end

  defp generate_cover_results(opts) do
    {:result, ok, _fail} = :cover.analyse(:coverage, :line)
    ignore = opts[:ignore_modules] || []
    modules = Enum.reject(:cover.modules(), &ignored?(&1, ignore))

    if summary_opts = Keyword.get(opts, :summary, true) do
      summary(ok, modules, summary_opts)
    end

    html(modules, opts)
  end

  defp ignored?(mod, ignores) do
    Enum.any?(ignores, &ignored_any?(mod, &1))
  end

  defp ignored_any?(mod, %Regex{} = re), do: Regex.match?(re, inspect(mod))
  defp ignored_any?(mod, other), do: mod == other

  defp html(modules, opts) do
    output = Keyword.get(opts, :output, "cover")
    File.mkdir_p!(output)

    for mod <- modules do
      {:ok, _} = :cover.analyse_to_file(mod, '#{output}/#{mod}.html', [:html])
    end

    Mix.shell().info("Generated HTML coverage results in #{inspect(output)} directory")
  end

  defp summary(results, keep, summary_opts) do
    {module_results, totals} = gather_coverage(results, keep)
    module_results = Enum.sort(module_results, :desc)
    print_summary(module_results, totals, summary_opts)

    if totals < get_threshold(summary_opts) do
      print_failed_threshold(totals, get_threshold(summary_opts))
      System.at_exit(fn _ -> exit({:shutdown, 3}) end)
    end

    :ok
  end

  defp gather_coverage(results, keep) do
    keep_set = MapSet.new(keep)

    # When gathering coverage results, we need to skip any
    # entry with line equal to 0 as those are generated code.
    #
    # We may also have multiple entries on the same line.
    # Each line is only considered once.
    #
    # We use ETS for performance, to avoid working with nested maps.
    table = :ets.new(__MODULE__, [:set, :private])

    try do
      for {{module, line}, cov} <- results, module in keep_set, line != 0 do
        case cov do
          {1, 0} -> :ets.insert(table, {{module, line}, true})
          {0, 1} -> :ets.insert_new(table, {{module, line}, false})
        end
      end

      module_results = for module <- keep, do: {read_cover_results(table, module), module}
      {module_results, read_cover_results(table, :_)}
    after
      :ets.delete(table)
    end
  end

  defp read_cover_results(table, module) do
    covered = :ets.select_count(table, [{{{module, :_}, true}, [], [true]}])
    not_covered = :ets.select_count(table, [{{{module, :_}, false}, [], [true]}])
    percentage(covered, not_covered)
  end

  defp percentage(0, 0), do: 100.0
  defp percentage(covered, not_covered), do: covered / (covered + not_covered) * 100

  defp print_summary(results, totals, true), do: print_summary(results, totals, [])

  defp print_summary(results, totals, opts) when is_list(opts) do
    threshold = get_threshold(opts)
    Mix.shell().info("Percentage | Module")
    Mix.shell().info("-----------|--------------------------")
    results |> Enum.sort() |> Enum.each(&display(&1, threshold))
    Mix.shell().info("-----------|--------------------------")
    display({totals, "Total"}, opts)
    Mix.shell().info("")
  end

  defp print_failed_threshold(totals, threshold) do
    Mix.shell().info("Coverage test failed, threshold not met:\n")
    Mix.shell().info("    Coverage:  #{format_number(totals, 6)}%")
    Mix.shell().info("    Threshold: #{format_number(threshold, 6)}%")
    Mix.shell().info("")
  end

  defp display({percentage, name}, threshold) do
    Mix.shell().info([
      color(percentage, threshold),
      format_number(percentage, 9),
      "%",
      :reset,
      " | ",
      format_name(name)
    ])
  end

  defp color(percentage, true), do: color(percentage, @default_threshold)
  defp color(_, false), do: ""
  defp color(percentage, threshold) when percentage >= threshold, do: :green
  defp color(_, _), do: :red

  defp format_number(number, length) when is_integer(number),
    do: format_number(number / 1, length)

  defp format_number(number, length), do: :io_lib.format("~#{length}.2f", [number])

  defp format_name(name) when is_binary(name), do: name
  defp format_name(mod) when is_atom(mod), do: inspect(mod)

  defp get_threshold(true), do: @default_threshold
  defp get_threshold(opts), do: Keyword.get(opts, :threshold, @default_threshold)
end