Packages

Actionable coverage report with your test results.

Current section

Files

Jump to
spry_cov lib spry_cov.ex
Raw

lib/spry_cov.ex

defmodule SpryCov do
@moduledoc """
To be used as `:test_coverage` `:tool` in `mix.exs`
## Configuration
```elixir
def project() do
[
...
test_coverage: [tool: SpryCov]
...
]
end
```
"""
alias SpryCov.Files
@default_threshold 100
@doc false
def start(compile_path, opts) do
monitor_ex_unit()
coverage_callback = start_coverage(compile_path, opts)
fn ->
%{failures: failures} = ex_unit_status()
if failures == 0 do
generate_cover_results(opts, coverage_callback)
end
end
end
defp monitor_ex_unit() do
pid = self()
ExUnit.after_suite(&send(pid, {:suite_status, &1}))
end
defp ex_unit_status() do
receive do
{:suite_status, status} -> status
after
0 -> raise "no suite status"
end
end
defp start_coverage(compile_path, opts) do
Mix.Tasks.Test.Coverage.start(compile_path, Keyword.put(opts, :summary, false))
end
defp module_path(module) do
module.module_info(:compile)[:source]
|> List.to_string()
|> Path.relative_to_cwd()
end
defp generate_cover_results(opts, coverage_callback) do
coverage_callback.()
{module_results, totals} = gather_coverage(opts)
summary_opts = Keyword.get(opts, :summary, true)
threshold = get_threshold(summary_opts)
if summary_opts do
summary(module_results, threshold)
end
print_total(totals)
if totals < threshold do
System.at_exit(fn _ -> exit({:shutdown, 3}) end)
end
:ok
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 summary(module_results, threshold) do
module_results =
module_results
|> Enum.filter(fn {coverage, _, _} -> coverage < threshold end)
|> filter_selected_modules()
|> Enum.sort(:desc)
if Enum.any?(module_results) do
print_summary(module_results, threshold)
end
:ok
end
defp filter_selected_modules(modules) do
mix_test_files = Files.mix_test_files()
if Enum.empty?(mix_test_files) do
modules
else
supposed_lib_files = Files.supposed_lib_files(mix_test_files)
Enum.filter(modules, fn {_, name, _} ->
file = module_path(name)
Enum.any?(supposed_lib_files, fn modified_file ->
String.starts_with?(file, modified_file)
end)
end)
end
end
defp print_total(totals) do
Mix.shell().info([:light_black, "SpryCov total coverage: #{format_number(totals, 6)}%"])
end
defp gather_coverage(opts) do
{:result, _ok = results, _fail} = :cover.analyse(:coverage, :line)
ignore = opts[:ignore_modules] || []
keep = Enum.reject(:cover.modules(), &ignored?(&1, ignore))
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, read_not_covered_lines(table, module)}
end
{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 read_not_covered_lines(table, module) do
:ets.select(table, [{{{module, :"$1"}, false}, [], [:"$$"]}])
|> Enum.flat_map(& &1)
|> Enum.sort()
end
defp percentage(0, 0), do: 100.0
defp percentage(covered, not_covered), do: covered / (covered + not_covered) * 100
defp print_summary(results, threshold) do
Mix.shell().info("The following files are missing coverage:")
results |> Enum.sort() |> Enum.each(&display(&1, threshold))
Mix.shell().info("")
end
defp display({percentage, name, not_covered_lines}, threshold) do
file_path = module_path(name)
Mix.shell().info([
String.pad_trailing(file_path, 60),
format_number(percentage, 9),
"% < #{format_number(threshold, 6)}% ",
format_name(name)
])
not_covered_lines
|> Enum.each(fn line ->
Mix.shell().info([
color(percentage, threshold),
" ",
file_path,
":",
Integer.to_string(line)
])
end)
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(mod) when is_atom(mod), do: inspect(mod)
defp get_threshold(opts) when is_boolean(opts), do: @default_threshold
defp get_threshold(opts), do: Keyword.get(opts, :threshold, @default_threshold)
end