Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow per-file minimum coverage #304

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,9 @@ to `false`:
- A custom path for html reports. This defaults to the htmlcov report in the excoveralls lib.
- `minimum_coverage`
- When set to a number greater than 0, this setting causes the `mix coveralls` and `mix coveralls.html` tasks to exit with a status code of 1 if test coverage falls below the specified threshold (defaults to 0). This is useful to interrupt CI pipelines with strict code coverage rules. Should be expressed as a number between 0 and 100 signifying the minimum percentage of lines covered.
- `minimum_file_coverage`
- A JSON object whose keys are filepaths, and whose values are numbers. When a filepath specifies a minimum coverage greater than 0, any `mix coveralls` or `mix coveralls.html` tasks will exit with a status code of 1 if that file's test coverage falls below the configured threshold. Should be expressed as a number between 0 and 100 signifying the minimum percentage of lines covered for any given file.
- When used in conjunction with `minimum_coverage`, overall project coverage is checked first before individual file coverages are checked.
- `html_filter_full_covered`
- A boolean, when `true` files with 100% coverage are not shown in the HTML report. Default to `false`.

Expand All @@ -449,6 +452,9 @@ Example configuration file:
"output_dir": "cover/",
"template_path": "custom/path/to/template/",
"minimum_coverage": 90,
"minimum_file_coverage": {
"lib/my_app/example.ex": 100
},
"xml_base_dir": "custom/path/for/xml/reports/",
"html_filter_full_covered": true
}
Expand Down
42 changes: 37 additions & 5 deletions lib/excoveralls/stats.ex
Original file line number Diff line number Diff line change
Expand Up @@ -226,18 +226,50 @@ defmodule ExCoveralls.Stats do
Exit the process with a status of 1 if coverage is below the minimum.
"""
def ensure_minimum_coverage(stats) do
coverage_options = ExCoveralls.Settings.get_coverage_options
minimum_coverage = coverage_options["minimum_coverage"] || 0
if minimum_coverage > 0, do: check_coverage_threshold(stats, minimum_coverage)
coverage_options = ExCoveralls.Settings.get_coverage_options()

check_coverage_threshold(stats, coverage_options["minimum_coverage"] || 0)
check_file_coverage_threshold(stats, coverage_options["minimum_file_coverage"] || %{})
end

defp check_coverage_threshold(stats, minimum_coverage) do
defp check_coverage_threshold(stats, minimum_coverage) when minimum_coverage > 0 do
result = source(stats)

if result.coverage < minimum_coverage do
message = "FAILED: Expected minimum coverage of #{minimum_coverage}%, got #{result.coverage}%."
IO.puts IO.ANSI.format([:red, :bright, message])
IO.puts(IO.ANSI.format([:red, :bright, message]))
exit({:shutdown, 1})
end

:ok
end

defp check_coverage_threshold(_stats, _minimum_coverage) do
:ok
end

defp check_file_coverage_threshold(stats, minimum_coverages) when is_map(minimum_coverages) do
file_results = stats |> source() |> Map.get(:files, []) |> Map.new(&{&1.filename, &1})

messages =
minimum_coverages
|> Enum.map(fn {file, minimum_coverage} -> {file_results[to_string(file)], minimum_coverage} end)
|> Enum.reject(fn {source, _minimum_coverage} -> is_nil(source) end)
|> Enum.map(fn {source, minimum_coverage} ->
if minimum_coverage > 0 && source.coverage < minimum_coverage do
"FAILED: Expected minimum coverage of #{minimum_coverage}% for `#{source.filename}`, got #{source.coverage}%."
end
end)

unless Enum.empty?(messages) or Enum.all?(messages, &is_nil/1) do
IO.puts(IO.ANSI.format([:red, :bright, Enum.join(messages, "\n\n")]))
exit({:shutdown, 1})
end

:ok
end

defp check_file_coverage_threshold(_stats, _minimum_coverages) do
:ok
end
end
121 changes: 121 additions & 0 deletions test/stats_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,125 @@ defmodule ExCoveralls.StatsTest do
assert result
end
end

describe "ensure_minimum_coverage/1" do
import ExUnit.CaptureIO

setup do
stats = fn opts ->
Enum.map(opts, fn opts ->
coverage = Map.get(opts, :coverage, 0)

name =
Map.get(
opts,
:name,
"test/fixtures/test#{System.unique_integer([:monotonic, :positive])}.ex"
)

%{
name: name,
source: @trimmed,
coverage: List.duplicate(1, coverage) ++ List.duplicate(0, 100 - coverage)
}
end)
end

{:ok, stats: stats}
end

test_with_mock "returns `:ok` when minimum coverage is exceeded",
%{stats: stats},
ExCoveralls.Settings,
[],
get_coverage_options: fn -> %{"minimum_coverage" => 75} end do
assert :ok = Stats.ensure_minimum_coverage(stats.([%{coverage: 100}]))
end

test_with_mock "returns `:ok` when minimum coverage is met",
%{stats: stats},
ExCoveralls.Settings,
[],
get_coverage_options: fn -> %{"minimum_coverage" => 75} end do
assert :ok = Stats.ensure_minimum_coverage(stats.([%{coverage: 75}]))
end

test_with_mock "exits when minimum coverage is not met",
%{stats: stats},
ExCoveralls.Settings,
[],
get_coverage_options: fn -> %{"minimum_coverage" => 75} end do
assert capture_io(fn ->
assert catch_exit(Stats.ensure_minimum_coverage(stats.([%{coverage: 50}])))
end) =~ "FAILED: Expected minimum coverage of 75%, got 50%"
end

test_with_mock "returns `:ok` when per file minimum coverage is exceeded",
%{stats: stats},
ExCoveralls.Settings,
[],
get_coverage_options: fn ->
%{"minimum_file_coverage" => %{"file_1.ex": 50, "file_2.ex": 15}}
end do
assert :ok =
Stats.ensure_minimum_coverage(
stats.([
%{name: "file_1.ex", coverage: 100},
%{name: "file_2.ex", coverage: 20},
%{name: "file_3.ex", coverage: 0}
])
)
end

test_with_mock "returns `:ok` when per file minimum coverage is met",
%{stats: stats},
ExCoveralls.Settings,
[],
get_coverage_options: fn ->
%{"minimum_file_coverage" => %{"file_1.ex": 50, "file_2.ex": 15}}
end do
assert :ok =
Stats.ensure_minimum_coverage(
stats.([
%{name: "file_1.ex", coverage: 50},
%{name: "file_2.ex", coverage: 15},
%{name: "file_3.ex", coverage: 0}
])
)
end

test_with_mock "exits when minimum coverage it not met",
%{stats: stats},
ExCoveralls.Settings,
[],
get_coverage_options: fn ->
%{"minimum_file_coverage" => %{"file_1.ex": 50, "file_2.ex": 15}}
end do
assert log =
capture_io(fn ->
assert catch_exit(
Stats.ensure_minimum_coverage(
stats.([
%{name: "file_1.ex", coverage: 49},
%{name: "file_2.ex", coverage: 10},
%{name: "file_3.ex", coverage: 0}
])
)
)
end)

assert log =~ "FAILED: Expected minimum coverage of 50% for `file_1.ex`, got 49%."
assert log =~ "FAILED: Expected minimum coverage of 15% for `file_2.ex`, got 10%."
end

test_with_mock "no-op if coverage options specify file that does not exist",
%{stats: stats},
ExCoveralls.Settings,
[],
get_coverage_options: fn ->
%{"minimum_file_coverage" => %{"does_not_exist.ex": 50}}
end do
assert :ok = Stats.ensure_minimum_coverage(stats.([%{name: "file_3.ex", coverage: 0}]))
end
end
end