Skip to content

Commit 7190f41

Browse files
authored
Support :group option in ExUnit (#13897)
Tests in the same group cannot run concurrently, but it can run concurrently with other groups.
1 parent 0c7459e commit 7190f41

File tree

4 files changed

+256
-16
lines changed

4 files changed

+256
-16
lines changed

lib/ex_unit/lib/ex_unit/case.ex

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ defmodule ExUnit.Case do
1212
It should be enabled only if tests do not change any global state.
1313
Defaults to `false`.
1414
15+
* `:group` - configures the group this module belongs to.
16+
Tests in the same group never run concurrently. Tests from different
17+
groups (or with no groups) can run concurrently when `async: true`
18+
is given. By default, belongs to no group (defaults to `nil`).
19+
1520
* `:register` - when `false`, does not register this module within
1621
ExUnit server. This means the module won't run when ExUnit suite runs.
1722
@@ -317,7 +322,7 @@ defmodule ExUnit.Case do
317322
end
318323

319324
{register?, opts} = Keyword.pop(opts, :register, true)
320-
{next_opts, opts} = Keyword.split(opts, [:async, :parameterize])
325+
{next_opts, opts} = Keyword.split(opts, [:async, :group, :parameterize])
321326

322327
if opts != [] do
323328
IO.warn("unknown options given to ExUnit.Case: #{inspect(opts)}")
@@ -552,6 +557,7 @@ defmodule ExUnit.Case do
552557

553558
opts = Module.get_attribute(module, :ex_unit_module, [])
554559
async? = Keyword.get(opts, :async, false)
560+
group = Keyword.get(opts, :group, nil)
555561
parameterize = Keyword.get(opts, :parameterize, nil)
556562

557563
if not (parameterize == nil or (is_list(parameterize) and Enum.all?(parameterize, &is_map/1))) do
@@ -569,7 +575,11 @@ defmodule ExUnit.Case do
569575
end
570576

571577
def __ex_unit__(:config) do
572-
{unquote(async?), unquote(Macro.escape(parameterize))}
578+
%{
579+
async?: unquote(async?),
580+
group: unquote(Macro.escape(group)),
581+
parameterize: unquote(Macro.escape(parameterize))
582+
}
573583
end
574584
end
575585
end

lib/ex_unit/lib/ex_unit/runner.ex

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,22 @@ defmodule ExUnit.Runner do
161161
running
162162
end
163163

164-
defp spawn_modules(config, [{module, params} | modules], async?, running) do
164+
defp spawn_modules(
165+
config,
166+
[{_group, group_modules} | modules],
167+
async?,
168+
running
169+
) do
165170
if max_failures_reached?(config) do
166171
running
167172
else
168-
{pid, ref} = spawn_monitor(fn -> run_module(config, module, async?, params) end)
173+
{pid, ref} =
174+
spawn_monitor(fn ->
175+
Enum.each(group_modules, fn {module, params} ->
176+
run_module(config, module, async?, params)
177+
end)
178+
end)
179+
169180
spawn_modules(config, modules, async?, Map.put(running, ref, pid))
170181
end
171182
end

lib/ex_unit/lib/ex_unit/server.ex

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,21 @@ defmodule ExUnit.Server do
99
GenServer.start_link(__MODULE__, :ok, name: @name)
1010
end
1111

12-
def add_module(name, {async?, parameterize}) do
12+
def add_module(name, config) do
13+
%{
14+
async?: async?,
15+
group: group,
16+
parameterize: parameterize
17+
} = config
18+
1319
modules =
1420
if parameterize do
1521
Enum.map(parameterize, &{name, &1})
1622
else
1723
[{name, %{}}]
1824
end
1925

20-
case GenServer.call(@name, {:add, async?, modules}, @timeout) do
26+
case GenServer.call(@name, {:add, {async?, group}, modules}, @timeout) do
2127
:ok ->
2228
:ok
2329

@@ -51,6 +57,7 @@ defmodule ExUnit.Server do
5157
state = %{
5258
loaded: System.monotonic_time(),
5359
waiting: nil,
60+
async_groups: %{},
5461
async_modules: :queue.new(),
5562
sync_modules: :queue.new()
5663
}
@@ -74,10 +81,20 @@ defmodule ExUnit.Server do
7481

7582
# Called by the runner when --repeat-until-failure is used.
7683
def handle_call({:restore_modules, async_modules, sync_modules}, _from, state) do
84+
{async_modules, async_groups} =
85+
Enum.reduce(async_modules, {[], []}, fn
86+
{nil, [module]}, {modules, groups} ->
87+
{[{:module, module} | modules], groups}
88+
89+
{group, group_modules}, {modules, groups} ->
90+
{[{:group, group} | modules], Map.put(groups, group, group_modules)}
91+
end)
92+
7793
{:reply, :ok,
7894
%{
7995
state
8096
| loaded: :done,
97+
async_groups: async_groups,
8198
async_modules: :queue.from_list(async_modules),
8299
sync_modules: :queue.from_list(sync_modules)
83100
}}
@@ -91,12 +108,18 @@ defmodule ExUnit.Server do
91108
when is_integer(loaded) do
92109
state =
93110
if uniq? do
111+
async_groups =
112+
Map.new(state.async_groups, fn {group, modules} ->
113+
{group, Enum.uniq(modules)}
114+
end)
115+
94116
async_modules = :queue.to_list(state.async_modules) |> Enum.uniq() |> :queue.from_list()
95117
sync_modules = :queue.to_list(state.sync_modules) |> Enum.uniq() |> :queue.from_list()
96118

97119
%{
98120
state
99-
| async_modules: async_modules,
121+
| async_groups: async_groups,
122+
async_modules: async_modules,
100123
sync_modules: sync_modules
101124
}
102125
else
@@ -107,26 +130,40 @@ defmodule ExUnit.Server do
107130
{:reply, diff, take_modules(%{state | loaded: :done})}
108131
end
109132

110-
def handle_call({:add, true, names}, _from, %{loaded: loaded} = state)
133+
def handle_call({:add, {false = _async, _group}, names}, _from, %{loaded: loaded} = state)
134+
when is_integer(loaded) do
135+
state =
136+
update_in(state.sync_modules, &Enum.reduce(names, &1, fn name, q -> :queue.in(name, q) end))
137+
138+
{:reply, :ok, state}
139+
end
140+
141+
def handle_call({:add, {true = _async, nil = _group}, names}, _from, %{loaded: loaded} = state)
111142
when is_integer(loaded) do
112143
state =
113144
update_in(
114145
state.async_modules,
115-
&Enum.reduce(names, &1, fn name, q -> :queue.in(name, q) end)
146+
&Enum.reduce(names, &1, fn name, q -> :queue.in({:module, name}, q) end)
116147
)
117148

118-
{:reply, :ok, take_modules(state)}
149+
{:reply, :ok, state}
119150
end
120151

121-
def handle_call({:add, false, names}, _from, %{loaded: loaded} = state)
152+
def handle_call({:add, {true = _async, group}, names}, _from, %{loaded: loaded} = state)
122153
when is_integer(loaded) do
123154
state =
124-
update_in(state.sync_modules, &Enum.reduce(names, &1, fn name, q -> :queue.in(name, q) end))
155+
case state.async_groups do
156+
%{^group => entries} = async_groups ->
157+
{%{async_groups | group => names ++ entries}, state.async_modules}
158+
159+
%{} = async_groups ->
160+
{Map.put(async_groups, group, names), :queue.in({:group, group}, state.async_modules)}
161+
end
125162

126163
{:reply, :ok, state}
127164
end
128165

129-
def handle_call({:add, _async?, _names}, _from, state),
166+
def handle_call({:add, {_async?, _group}, _names}, _from, state),
130167
do: {:reply, :already_running, state}
131168

132169
defp take_modules(%{waiting: nil} = state) do
@@ -145,9 +182,26 @@ defmodule ExUnit.Server do
145182
state
146183

147184
true ->
148-
{modules, async_modules} = take_until(count, state.async_modules)
149-
GenServer.reply(from, modules)
150-
%{state | async_modules: async_modules, waiting: nil}
185+
{async_modules, remaining_modules} = take_until(count, state.async_modules)
186+
187+
{async_modules, remaining_groups} =
188+
Enum.reduce(async_modules, {[], state.async_groups}, fn
189+
{:module, module}, {collected_modules, async_groups} ->
190+
{[{nil, [module]} | collected_modules], async_groups}
191+
192+
{:group, group}, {collected_modules, async_groups} ->
193+
{group_modules, async_groups} = Map.pop!(async_groups, group)
194+
{[{group, Enum.reverse(group_modules)} | collected_modules], async_groups}
195+
end)
196+
197+
GenServer.reply(from, Enum.reverse(async_modules))
198+
199+
%{
200+
state
201+
| async_groups: remaining_groups,
202+
async_modules: remaining_modules,
203+
waiting: nil
204+
}
151205
end
152206
end
153207

lib/ex_unit/test/ex_unit_test.exs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,123 @@ defmodule ExUnitTest do
715715
assert_receive {:tmp_dir, tmp_dir2} when tmp_dir1 != tmp_dir2
716716
end
717717

718+
test "async tests run concurrently" do
719+
Process.register(self(), :async_tests)
720+
721+
defmodule FirstAsyncTest do
722+
use ExUnit.Case, async: true
723+
724+
test "first test" do
725+
send(:async_tests, {:first_test, :started})
726+
Process.sleep(10)
727+
assert true
728+
send(:async_tests, {:first_test, :finished})
729+
end
730+
end
731+
732+
defmodule SecondAsyncTest do
733+
use ExUnit.Case, async: true
734+
735+
test "second test" do
736+
send(:async_tests, {:second_test, :started})
737+
Process.sleep(15)
738+
assert true
739+
send(:async_tests, {:second_test, :finished})
740+
end
741+
end
742+
743+
configure_and_reload_on_exit(max_cases: 2)
744+
745+
test_task =
746+
Task.async(fn ->
747+
capture_io(fn -> ExUnit.run() end)
748+
end)
749+
750+
# Expected test distribution through time
751+
#
752+
# Time (ms): 0 10 20
753+
# |-----|-----|
754+
# CPU0: ( 1 )
755+
# CPU1: ( 2 )
756+
assert_receive({:first_test, :started}, 5)
757+
assert_receive({:second_test, :started}, 5)
758+
759+
# make sure we don't leave the task running after the outer test finishes
760+
Task.await(test_task)
761+
end
762+
763+
test "async tests run concurrently respecting groups" do
764+
Process.register(self(), :async_grouped_tests)
765+
766+
defmodule RedOneTest do
767+
use ExUnit.Case, async: true, group: :red
768+
769+
test "red one test" do
770+
send(:async_grouped_tests, {:red_one, :started})
771+
Process.sleep(30)
772+
assert true
773+
send(:async_grouped_tests, {:red_one, :finished})
774+
end
775+
end
776+
777+
defmodule RedTwoTest do
778+
use ExUnit.Case, async: true, group: :red
779+
780+
test "red two test" do
781+
send(:async_grouped_tests, {:red_two, :started})
782+
Process.sleep(10)
783+
assert true
784+
send(:async_grouped_tests, {:red_two, :finished})
785+
end
786+
end
787+
788+
defmodule BlueOneTest do
789+
use ExUnit.Case, async: true, group: :blue
790+
791+
test "blue one test" do
792+
send(:async_grouped_tests, {:blue_one, :started})
793+
Process.sleep(10)
794+
assert true
795+
send(:async_grouped_tests, {:blue_one, :finished})
796+
end
797+
end
798+
799+
defmodule BlueTwoTest do
800+
use ExUnit.Case, async: true, group: :blue
801+
802+
test "blue two test" do
803+
send(:async_grouped_tests, {:blue_two, :started})
804+
Process.sleep(10)
805+
assert true
806+
send(:async_grouped_tests, {:blue_two, :finished})
807+
end
808+
end
809+
810+
configure_and_reload_on_exit(max_cases: 4)
811+
812+
test_task =
813+
Task.async(fn ->
814+
capture_io(fn -> ExUnit.run() end)
815+
end)
816+
817+
# Expected test distribution through time
818+
#
819+
# Time (ms): 0 10 20 30 40
820+
# |-----|-----|-----|-----|
821+
# CPU0: ( R1 )( R2 )
822+
# CPU1: ( B1 )( B2 )
823+
assert_receive({:red_one, :started}, 5)
824+
assert_receive({:blue_one, :started}, 5)
825+
refute_receive({:red_two, :started}, 25)
826+
assert_received({:blue_one, :finished})
827+
assert_received({:blue_two, :started})
828+
assert_receive({:blue_two, :finished}, 15)
829+
assert_receive({:red_two, :started}, 15)
830+
831+
# make sure we don't leave the task running after the outer test finishes
832+
Task.await(test_task)
833+
end
834+
718835
describe "after_suite/1" do
719836
test "executes all callbacks set in reverse order" do
720837
Process.register(self(), :after_suite_test_process)
@@ -1101,6 +1218,54 @@ defmodule ExUnitTest do
11011218
assert third =~ "ThirdTestFIFO"
11021219
end
11031220

1221+
test "async test groups are run in compile order (FIFO)" do
1222+
defmodule RedOneFIFO do
1223+
use ExUnit.Case, async: true, group: :red
1224+
1225+
test "red one test" do
1226+
assert true
1227+
end
1228+
end
1229+
1230+
defmodule BlueOneFIFO do
1231+
use ExUnit.Case, async: true, group: :blue
1232+
1233+
test "blue one test" do
1234+
assert true
1235+
end
1236+
end
1237+
1238+
defmodule RedTwoFIFO do
1239+
use ExUnit.Case, async: true, group: :red
1240+
1241+
test "red two test" do
1242+
assert true
1243+
end
1244+
end
1245+
1246+
defmodule BlueTwoFIFO do
1247+
use ExUnit.Case, async: true, group: :blue
1248+
1249+
test "blue two test" do
1250+
assert true
1251+
end
1252+
end
1253+
1254+
configure_and_reload_on_exit(trace: true)
1255+
1256+
output =
1257+
capture_io(fn ->
1258+
assert ExUnit.run() == %{total: 4, failures: 0, excluded: 0, skipped: 0}
1259+
end)
1260+
1261+
[_, first, second, third, fourth | _] = String.split(output, "\n\n")
1262+
1263+
assert first =~ "RedOneFIFO"
1264+
assert second =~ "RedTwoFIFO"
1265+
assert third =~ "BlueOneFIFO"
1266+
assert fourth =~ "BlueTwoFIFO"
1267+
end
1268+
11041269
test "can filter async tests" do
11051270
defmodule FirstTestAsyncTrue do
11061271
use ExUnit.Case, async: true

0 commit comments

Comments
 (0)