Skip to content

Commit 1622eb6

Browse files
author
José Valim
committed
Merge pull request #1909 from vanstee/exunit-support-filters
Support ExUnit filters
2 parents d769f8f + b536a2a commit 1622eb6

File tree

10 files changed

+269
-11
lines changed

10 files changed

+269
-11
lines changed

lib/ex_unit/lib/ex_unit.ex

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,10 @@ defmodule ExUnit do
144144
and prints each test case and test while running;
145145
146146
* `:autorun` - If ExUnit should run by default on exit, defaults to `true`;
147+
148+
* `:include` - Specify which tests are run by skipping tests that do not match the filter
149+
150+
* `:exclude` - Specify which tests are run by skipping tests that match the filter
147151
"""
148152
def configure(options) do
149153
Enum.each options, fn { k, v } ->
@@ -169,4 +173,22 @@ defmodule ExUnit do
169173
opts = Keyword.put_new(configuration, :color, IO.ANSI.terminal?)
170174
ExUnit.Runner.run async, sync, opts, load_us
171175
end
176+
177+
@doc false
178+
def parse_filters(filters) do
179+
Enum.map filters, fn filter ->
180+
[key, value] = case String.split(filter, ":", global: false) do
181+
[key, value] -> [key, value]
182+
[key] -> [key, true]
183+
end
184+
185+
value = case value do
186+
"true" -> true
187+
"false" -> false
188+
value -> value
189+
end
190+
191+
{ Kernel.binary_to_atom(key), value }
192+
end
193+
end
172194
end

lib/ex_unit/lib/ex_unit/case.ex

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,37 @@ defmodule ExUnit.Case do
110110
* `:test` - the test name
111111
* `:line` - the line the test was defined
112112
113+
## Filters
114+
115+
Tags can also be used to identify specific tests, which can then be included
116+
or excluded using filters. Filters are defined as key-value pairs, similar to
117+
tags, and are used to match against the tags given for each test. For example
118+
the following command will skip any test that contains the `:os` tag but has
119+
a value other than `"unix"`.
120+
121+
mix test --include os:unix
122+
123+
If your tags are defined using boolean values, you can use the shorthand
124+
version by only specifying the tag name. The value will be automatically set
125+
to `true`. To skip all tests with a tag of `slow: true` run the following
126+
command:
127+
128+
mix test --exclude slow
129+
130+
Filters can also be combined to further limit the tests to be run. When
131+
defining filters with the same tag name, tests that match either filter will
132+
be run. The following command will skip any test that contains the `:os` tag
133+
but has a value other than `"unix"` or `"win32"`:
134+
135+
mix test --include os:unix --include os:win32
136+
137+
However, if multiple filters with different tag names are given, only tests
138+
that match the filter defined for each unique tag name will be run. This
139+
command will only run tests that have both `os: "unix"` and `type: "unit"`
140+
tags.
141+
142+
mix test --include os:unix --include type:unit
143+
113144
"""
114145

115146
@doc false

lib/ex_unit/lib/ex_unit/cli_formatter.ex

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ defmodule ExUnit.CLIFormatter do
55

66
use GenServer.Behaviour
77

8-
import ExUnit.Formatter, only: [format_time: 2, format_test_failure: 5, format_test_case_failure: 4]
8+
import ExUnit.Formatter, only: [format_time: 2, format_filters: 2, format_test_failure: 5, format_test_case_failure: 4]
99

1010
defrecord Config, tests_counter: 0, invalids_counter: 0, failures_counter: 0,
11-
trace: false, color: true, previous: nil
11+
skips_counter: 0, trace: false, color: true, previous: nil
1212

1313
## Behaviour
1414

@@ -40,6 +40,7 @@ defmodule ExUnit.CLIFormatter do
4040
## Callbacks
4141

4242
def init(opts) do
43+
print_filters(Keyword.take(opts, [:include, :exclude]))
4344
{ :ok, Config.new(opts) }
4445
end
4546

@@ -77,6 +78,10 @@ defmodule ExUnit.CLIFormatter do
7778
.update_invalids_counter(&(&1 + 1)) }
7879
end
7980

81+
def handle_cast({ :test_finished, ExUnit.Test[state: { :skip, _ }] }, config = Config[]) do
82+
{ :noreply, config.previous(:skip).update_skips_counter(&(&1 + 1)) }
83+
end
84+
8085
def handle_cast({ :test_finished, test }, config) do
8186
if config.trace do
8287
IO.puts failure(trace_test_result(test), config)
@@ -150,6 +155,11 @@ defmodule ExUnit.CLIFormatter do
150155
end
151156
end
152157

158+
defp print_filters([include: include, exclude: exclude]) do
159+
unless Enum.empty?(include), do: IO.puts format_filters(include, :include)
160+
unless Enum.empty?(exclude), do: IO.puts format_filters(exclude, :exclude)
161+
end
162+
153163
defp print_test_failure(ExUnit.Test[name: name, case: mod, state: { :failed, tuple }], config) do
154164
formatted = format_test_failure(mod, name, tuple, config.failures_counter + 1, &formatter(&1, &2, config))
155165
print_any_failure formatted, config

lib/ex_unit/lib/ex_unit/formatter.ex

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,23 @@ defmodule ExUnit.Formatter do
6767
end
6868
end
6969

70+
@doc """
71+
Formats filters used to constain cases to be run.
72+
73+
## Examples
74+
75+
iex> format_filters([run: true, slow: false], :include)
76+
"Including tags: [run: true, slow: false]"
77+
78+
"""
79+
@spec format_filters(Keyword.t, atom) :: String.t
80+
def format_filters(filters, type) do
81+
case type do
82+
:include -> "Including tags: #{inspect filters}"
83+
:exclude -> "Excluding tags: #{inspect filters}"
84+
end
85+
end
86+
7087
@doc %S"""
7188
Receives a test and formats its failure.
7289
"""

lib/ex_unit/lib/ex_unit/runner.ex

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ defmodule ExUnit.Runner do
22
@moduledoc false
33

44
defrecord Config, formatter: ExUnit.CLIFormatter, formatter_id: nil,
5-
max_cases: 4, taken_cases: 0, async_cases: [], sync_cases: []
5+
max_cases: 4, taken_cases: 0, async_cases: [],
6+
sync_cases: [], include: nil, exclude: nil
67

78
def run(async, sync, opts, load_us) do
89
opts = normalize_opts(opts)
@@ -125,9 +126,22 @@ defmodule ExUnit.Runner do
125126
end
126127

127128
defp run_test(config, test, context) do
128-
case_name = test.case
129129
config.formatter.test_started(config.formatter_id, test)
130130

131+
filters = combine_filters([include: config.include, exclude: config.exclude])
132+
result = evaluate_filters(filters, test.tags)
133+
134+
test = case result do
135+
{ :error, tag } -> skip_test(test, tag)
136+
:ok -> spawn_test(config, test, context)
137+
end
138+
139+
config.formatter.test_finished(config.formatter_id, test)
140+
end
141+
142+
defp spawn_test(_config, test, context) do
143+
case_name = test.case
144+
131145
# Run test in a new process so that we can trap exits for a single test
132146
self_pid = self
133147
{ test_pid, test_ref } = Process.spawn_monitor fn ->
@@ -157,13 +171,16 @@ defmodule ExUnit.Runner do
157171

158172
receive do
159173
{ ^test_pid, :test_finished, test } ->
160-
config.formatter.test_finished(config.formatter_id, test)
174+
test
161175
{ :DOWN, ^test_ref, :process, ^test_pid, { error, stacktrace } } ->
162-
test = test.state { :failed, { :EXIT, error, prune_stacktrace(stacktrace) } }
163-
config.formatter.test_finished(config.formatter_id, test)
176+
test.state { :failed, { :EXIT, error, prune_stacktrace(stacktrace) } }
164177
end
165178
end
166179

180+
defp skip_test(test, mismatch) do
181+
test.state { :skip, "due to #{mismatch} filter" }
182+
end
183+
167184
## Helpers
168185

169186
defp take_async_cases(Config[] = config, count) do
@@ -182,6 +199,42 @@ defmodule ExUnit.Runner do
182199
end
183200
end
184201

202+
def evaluate_filters(filters, tags) do
203+
Enum.find_value tags, :ok, fn { tag_key, _ } = tag ->
204+
unless tag_accepted?(filters, tag), do: { :error, tag_key }
205+
end
206+
end
207+
208+
defp tag_accepted?([include: include, exclude: exclude], tag) do
209+
tag_included?(include, tag) and not tag_excluded?(exclude, tag)
210+
end
211+
212+
defp tag_included?(include, { tag_key, tag_value }) do
213+
case Dict.fetch(include, tag_key) do
214+
{ :ok, allowed } -> tag_value in allowed
215+
:error -> true
216+
end
217+
end
218+
219+
defp tag_excluded?(exclude, { tag_key, tag_value }) do
220+
case Dict.fetch(exclude, tag_key) do
221+
{ :ok, forbidden } -> tag_value in forbidden
222+
:error -> false
223+
end
224+
end
225+
226+
defp combine_filters([include: include, exclude: exclude]) do
227+
include = group_by_key(include)
228+
exclude = group_by_key(exclude)
229+
[include: include, exclude: exclude]
230+
end
231+
232+
defp group_by_key(dict) do
233+
Enum.reduce dict, HashDict.new, fn { key, value }, acc ->
234+
Dict.update acc, key, [value], &[value|&1]
235+
end
236+
end
237+
185238
defp pruned_stacktrace, do: prune_stacktrace(System.stacktrace)
186239

187240
# Assertions can pop-up in the middle of the stack

lib/ex_unit/mix.exs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ defmodule ExUnit.Mixfile do
1111
env: [
1212
autorun: true,
1313
trace: false,
14-
formatter: ExUnit.CLIFormatter ] ]
14+
formatter: ExUnit.CLIFormatter,
15+
include: [],
16+
exclude: [] ] ]
1517
end
1618
end

lib/ex_unit/test/ex_unit/formatter_test.exs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ defmodule ExUnit.FormatterTest do
1818
end
1919
end
2020

21+
test "formats test case filters" do
22+
filters = [run: true, slow: false]
23+
assert format_filters(filters, :include) =~ "Including tags: [run: true, slow: false]"
24+
assert format_filters(filters, :exclude) =~ "Excluding tags: [run: true, slow: false]"
25+
end
26+
2127
test "formats test errors" do
2228
failure = { :error, catch_error(raise "oops"), [] }
2329
assert format_test_failure(Hello, :world, failure, 1, nil) =~ """
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
Code.require_file "../test_helper.exs", __DIR__
2+
3+
defmodule ExUnit.RunnerTest do
4+
use ExUnit.Case, async: true
5+
6+
test "filters with matching tags are ored and non-matching tags are anded" do
7+
filters = [include: [os: [:unix, :win32], type: [:unit]], exclude: []]
8+
9+
assert :ok = ExUnit.Runner.evaluate_filters(filters, [os: :unix])
10+
assert :ok = ExUnit.Runner.evaluate_filters(filters, [os: :unix, type: :unit])
11+
refute :ok = ExUnit.Runner.evaluate_filters(filters, [os: :unix, type: :integration])
12+
assert :ok = ExUnit.Runner.evaluate_filters(filters, [os: :win32])
13+
assert :ok = ExUnit.Runner.evaluate_filters(filters, [os: :win32, type: :unit])
14+
refute :ok = ExUnit.Runner.evaluate_filters(filters, [os: :win32, type: :integration])
15+
assert :ok = ExUnit.Runner.evaluate_filters(filters, [os: :unix, os: :win32])
16+
end
17+
end

lib/ex_unit/test/ex_unit_test.exs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,54 @@ defmodule ExUnit.NilFormatter do
2828
end
2929
end
3030

31+
defmodule ExUnit.TestsCounterFormatter do
32+
@timeout 30_000
33+
@behaviour ExUnit.Formatter
34+
35+
use GenServer.Behaviour
36+
37+
def suite_started(opts) do
38+
{ :ok, pid } = :gen_server.start_link(__MODULE__, opts, [])
39+
pid
40+
end
41+
42+
def suite_finished(id, _run_us, _load_us) do
43+
:gen_server.call(id, { :suite_finished }, @timeout)
44+
end
45+
46+
def case_started(_id, _test_case) do
47+
:ok
48+
end
49+
50+
def case_finished(_id, _test_case) do
51+
:ok
52+
end
53+
54+
def test_started(_id, _test) do
55+
:ok
56+
end
57+
58+
def test_finished(id, test) do
59+
:gen_server.cast(id, { :test_finished, test })
60+
end
61+
62+
def init(_opts) do
63+
{ :ok, 0 }
64+
end
65+
66+
def handle_call({ :suite_finished }, _from, tests_counter) do
67+
{ :stop, :normal, tests_counter, tests_counter }
68+
end
69+
70+
def handle_cast({ :test_finished, ExUnit.Test[state: { :skip, _ }] }, tests_counter) do
71+
{ :noreply, tests_counter }
72+
end
73+
74+
def handle_cast({ :test_finished, _ }, tests_counter) do
75+
{ :noreply, tests_counter + 1 }
76+
end
77+
end
78+
3179
defmodule ExUnitTest do
3280
use ExUnit.Case, async: false
3381

@@ -48,4 +96,41 @@ defmodule ExUnitTest do
4896

4997
assert ExUnit.run == 1
5098
end
99+
100+
test "filtering cases with tags" do
101+
ExUnit.configure(formatter: ExUnit.TestsCounterFormatter)
102+
103+
defmodule ParityTest do
104+
use ExUnit.Case, async: false
105+
106+
test "zero", do: assert true
107+
108+
@tag even: false
109+
test "one", do: assert true
110+
111+
@tag even: true
112+
test "two", do: assert true
113+
114+
@tag even: false
115+
test "three", do: assert true
116+
end
117+
118+
test_cases = ExUnit.Server.start_run
119+
120+
assert run_with_filter([include: [even: true]], test_cases) == 2
121+
assert run_with_filter([exclude: [even: true]], test_cases) == 3
122+
assert run_with_filter([include: [even: false]], test_cases) == 3
123+
assert run_with_filter([exclude: [even: false]], test_cases) == 2
124+
end
125+
126+
test "parsing filters" do
127+
assert ExUnit.parse_filters(["run"]) == [run: true]
128+
assert ExUnit.parse_filters(["run:true"]) == [run: true]
129+
assert ExUnit.parse_filters(["run:test"]) == [run: "test"]
130+
end
131+
132+
defp run_with_filter(filters, { async, sync, load_us }) do
133+
opts = Keyword.merge(ExUnit.configuration, filters)
134+
ExUnit.Runner.run(async, sync, opts, load_us)
135+
end
51136
end

0 commit comments

Comments
 (0)