Skip to content

Commit 058b224

Browse files
author
José Valim
authored
Add mix test partitioning (#9422)
1 parent 7c102e8 commit 058b224

File tree

2 files changed

+136
-44
lines changed

2 files changed

+136
-44
lines changed

lib/mix/lib/mix/tasks/test.ex

Lines changed: 107 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ defmodule Mix.Tasks.Test do
181181
* `--no-elixir-version-check` - does not check the Elixir version from `mix.exs`
182182
* `--no-start` - does not start applications after compilation
183183
* `--only` - runs only tests that match the filter
184+
* `--partitions` - sets the amount of partitions to split tests in. This option
185+
requires the `MIX_TEST_PARTITION` environment variable to be set. See the
186+
"OS Processes Partitioning" section for more information
184187
* `--preload-modules` - preloads all modules defined in applications
185188
* `--raise` - raises if the test suite failed
186189
* `--seed` - seeds the random number generator used to randomize the order of tests;
@@ -189,12 +192,26 @@ defmodule Mix.Tasks.Test do
189192
Automatically sets `--trace` and `--preload-modules`
190193
* `--stale` - runs only tests which reference modules that changed since the
191194
last time tests were ran with `--stale`. You can read more about this option
192-
in the "Stale" section below
195+
in the "The --stale option" section below
193196
* `--timeout` - sets the timeout for the tests
194197
* `--trace` - runs tests with detailed reporting. Automatically sets `--max-cases` to `1`.
195198
Note that in trace mode test timeouts will be ignored as timeout is set to `:infinity`
196199
197-
See `ExUnit.configure/1` for more information on configuration options.
200+
## Configuration
201+
202+
These configurations can be set in the `def project` section of your `mix.exs`:
203+
204+
* `:test_paths` - list of paths containing test files. Defaults to
205+
`["test"]` if the `test` directory exists; otherwise, it defaults to `[]`.
206+
It is expected that all test paths contain a `test_helper.exs` file
207+
208+
* `:test_pattern` - a pattern to load test files. Defaults to `*_test.exs`
209+
210+
* `:warn_test_pattern` - a pattern to match potentially misnamed test files
211+
and display a warning. Defaults to `*_test.ex`
212+
213+
* `:test_coverage` - a set of options to be passed down to the coverage
214+
mechanism
198215
199216
## Filters
200217
@@ -251,20 +268,6 @@ defmodule Mix.Tasks.Test do
251268
If a given line starts a `describe` block, that line filter runs all tests in it.
252269
Otherwise, it runs the closest test on or before the given line number.
253270
254-
## Configuration
255-
256-
* `:test_paths` - list of paths containing test files. Defaults to
257-
`["test"]` if the `test` directory exists; otherwise, it defaults to `[]`.
258-
It is expected that all test paths contain a `test_helper.exs` file
259-
260-
* `:test_pattern` - a pattern to load test files. Defaults to `*_test.exs`
261-
262-
* `:warn_test_pattern` - a pattern to match potentially misnamed test files
263-
and display a warning. Defaults to `*_test.ex`
264-
265-
* `:test_coverage` - a set of options to be passed down to the coverage
266-
mechanism
267-
268271
## Coverage
269272
270273
The `:test_coverage` configuration accepts the following options:
@@ -293,9 +296,35 @@ defmodule Mix.Tasks.Test do
293296
It must return either `nil` or an anonymous function of zero arity that will
294297
be run after the test suite is done.
295298
296-
## "Stale"
299+
## OS Processes Partitioning
300+
301+
While ExUnit supports the ability to run tests concurrently within the same
302+
Elixir instance, it is not always possible to run all tests concurrently. For
303+
example, some tests may rely on global resources.
304+
305+
For this reason, `mix test` supports partitioning the test files across
306+
different Elixir instances. This is done by setting the `--partitions` option
307+
to an integer, with the number of partitions, and setting the `MIX_TEST_PARTITION`
308+
environment variable to control which test partition that particular instance
309+
is running. This can also be useful if you want to distribute testing across
310+
multiple machines.
311+
312+
For example, to split a test suite into 4 partitions and run them, you would
313+
use the following commands:
297314
298-
The `--stale` command line option attempts to run only those test files which
315+
MIX_TEST_PARTITION=1 mix test --partitions 4
316+
MIX_TEST_PARTITION=2 mix test --partitions 4
317+
MIX_TEST_PARTITION=3 mix test --partitions 4
318+
MIX_TEST_PARTITION=4 mix test --partitions 4
319+
320+
The test files are sorted and distributed in a round-robin fashion. Note the
321+
partition itself is given as an environment variable so it can be accessed in
322+
configuration files and test scripts. For example, it can be used to setup a
323+
different database instance per partition in `config/test.exs`.
324+
325+
## The --stale option
326+
327+
The `--stale` command line option attempts to run only the test files which
299328
reference modules that have changed since the last time you ran this task with
300329
`--stale`.
301330
@@ -304,6 +333,9 @@ defmodule Mix.Tasks.Test do
304333
references (and any modules those modules reference, recursively) were modified
305334
since the last run with `--stale`. A test file is also marked "stale" if it has
306335
been changed since the last run with `--stale`.
336+
337+
The `--stale` option is extremely useful for software iteration, allowing you to
338+
run only the relevant tests as you perform changes to the codebase.
307339
"""
308340

309341
@switches [
@@ -329,6 +361,7 @@ defmodule Mix.Tasks.Test do
329361
listen_on_stdin: :boolean,
330362
formatter: :keep,
331363
slowest: :integer,
364+
partitions: :integer,
332365
preload_modules: :boolean
333366
]
334367

@@ -421,46 +454,45 @@ defmodule Mix.Tasks.Test do
421454
{:error, {:already_loaded, :ex_unit}} -> :ok
422455
end
423456

424-
# The test helper may change the Mix.shell(), so let's make sure to revert it later
457+
# The test helper may change the Mix.shell(), so revert it whenever we raise and after suite
425458
shell = Mix.shell()
426459

427460
# Configure ExUnit now and then again so the task options override test_helper.exs
428461
{ex_unit_opts, allowed_files} = process_ex_unit_opts(opts)
429462
ExUnit.configure(ex_unit_opts)
430463

431464
test_paths = project[:test_paths] || default_test_paths()
432-
Enum.each(test_paths, &require_test_helper(&1))
465+
Enum.each(test_paths, &require_test_helper(shell, &1))
433466
ExUnit.configure(merge_helper_opts(ex_unit_opts))
434467

435468
# Finally parse, require and load the files
436-
test_files = parse_files(files, test_paths)
469+
test_files = parse_files(files, shell, test_paths)
437470
test_pattern = project[:test_pattern] || "*_test.exs"
438471
warn_test_pattern = project[:warn_test_pattern] || "*_test.ex"
439472

440473
matched_test_files =
441474
test_files
442475
|> Mix.Utils.extract_files(test_pattern)
443476
|> filter_to_allowed_files(allowed_files)
477+
|> filter_by_partition(shell, opts)
444478

445479
display_warn_test_pattern(test_files, test_pattern, matched_test_files, warn_test_pattern)
446480

447-
results = CT.require_and_run(matched_test_files, test_paths, opts)
448-
Mix.shell(shell)
449-
450-
case results do
481+
case CT.require_and_run(matched_test_files, test_paths, opts) do
451482
{:ok, %{excluded: excluded, failures: failures, total: total}} ->
483+
Mix.shell(shell)
452484
cover && cover.()
453485

454486
cond do
455487
failures > 0 and opts[:raise] ->
456-
Mix.raise("\"mix test\" failed")
488+
raise_with_shell(shell, "\"mix test\" failed")
457489

458490
failures > 0 ->
459491
System.at_exit(fn _ -> exit({:shutdown, 1}) end)
460492

461493
excluded == total and Keyword.has_key?(opts, :only) ->
462494
message = "The --only option was given to \"mix test\" but no test was executed"
463-
raise_or_error_at_exit(message, opts)
495+
raise_or_error_at_exit(shell, message, opts)
464496

465497
true ->
466498
:ok
@@ -476,17 +508,22 @@ defmodule Mix.Tasks.Test do
476508

477509
true ->
478510
message = "Paths given to \"mix test\" did not match any directory/file: "
479-
raise_or_error_at_exit(message <> Enum.join(files, ", "), opts)
511+
raise_or_error_at_exit(shell, message <> Enum.join(files, ", "), opts)
480512
end
481513

482514
:ok
483515
end
484516
end
485517

486-
defp raise_or_error_at_exit(message, opts) do
518+
defp raise_with_shell(shell, message) do
519+
Mix.shell(shell)
520+
Mix.raise(message)
521+
end
522+
523+
defp raise_or_error_at_exit(shell, message, opts) do
487524
cond do
488525
opts[:raise] ->
489-
Mix.raise(message)
526+
raise_with_shell(shell, message)
490527

491528
Mix.Task.recursing?() ->
492529
Mix.shell().info(message)
@@ -525,10 +562,7 @@ defmodule Mix.Tasks.Test do
525562

526563
@doc false
527564
def process_ex_unit_opts(opts) do
528-
{opts, allowed_files} =
529-
opts
530-
|> manifest_opts()
531-
|> failed_opts()
565+
{opts, allowed_files} = manifest_opts(opts)
532566

533567
opts =
534568
opts
@@ -559,21 +593,21 @@ defmodule Mix.Tasks.Test do
559593
[autorun: false] ++ opts
560594
end
561595

562-
defp parse_files([], test_paths) do
596+
defp parse_files([], _shell, test_paths) do
563597
test_paths
564598
end
565599

566-
defp parse_files([single_file], _test_paths) do
600+
defp parse_files([single_file], _shell, _test_paths) do
567601
# Check if the single file path matches test/path/to_test.exs:123. If it does,
568602
# apply "--only line:123" and trim the trailing :123 part.
569603
{single_file, opts} = ExUnit.Filters.parse_path(single_file)
570604
ExUnit.configure(opts)
571605
[single_file]
572606
end
573607

574-
defp parse_files(files, _test_paths) do
608+
defp parse_files(files, shell, _test_paths) do
575609
if Enum.any?(files, &match?({_, [_ | _]}, ExUnit.Filters.parse_path(&1))) do
576-
Mix.raise("Line numbers can only be used when running a single test file")
610+
raise_with_shell(shell, "Line numbers can only be used when running a single test file")
577611
else
578612
files
579613
end
@@ -620,16 +654,14 @@ defmodule Mix.Tasks.Test do
620654

621655
defp manifest_opts(opts) do
622656
manifest_file = Path.join(Mix.Project.manifest_path(), @manifest_file_name)
623-
Keyword.put(opts, :failures_manifest_file, manifest_file)
624-
end
657+
opts = Keyword.put(opts, :failures_manifest_file, manifest_file)
625658

626-
defp failed_opts(opts) do
627659
if opts[:failed] do
628660
if opts[:stale] do
629661
Mix.raise("Combining --failed and --stale is not supported.")
630662
end
631663

632-
{allowed_files, failed_ids} = ExUnit.Filters.failure_info(opts[:failures_manifest_file])
664+
{allowed_files, failed_ids} = ExUnit.Filters.failure_info(manifest_file)
633665
{Keyword.put(opts, :only_test_ids, failed_ids), allowed_files}
634666
else
635667
{opts, nil}
@@ -642,6 +674,34 @@ defmodule Mix.Tasks.Test do
642674
Enum.filter(matched_test_files, &MapSet.member?(allowed_files, Path.expand(&1)))
643675
end
644676

677+
defp filter_by_partition(files, shell, opts) do
678+
if total = opts[:partitions] do
679+
partition = System.get_env("MIX_TEST_PARTITION")
680+
681+
case partition && Integer.parse(partition) do
682+
{partition, ""} when partition in 1..total ->
683+
partition = partition - 1
684+
685+
# We sort the files because Path.wildcard does not guarantee
686+
# ordering, so different OSes could return a different order,
687+
# meaning run across OSes on different partitions could run
688+
# duplicate files.
689+
for {file, index} <- Enum.with_index(Enum.sort(files)),
690+
rem(index, total) == partition,
691+
do: file
692+
693+
_ ->
694+
raise_with_shell(
695+
shell,
696+
"The MIX_TEST_PARTITION environment variable must be set to an integer between " <>
697+
"1..#{total} when the --partitions option is set, got: #{inspect(partition)}"
698+
)
699+
end
700+
else
701+
files
702+
end
703+
end
704+
645705
defp color_opts(opts) do
646706
case Keyword.fetch(opts, :color) do
647707
{:ok, enabled?} ->
@@ -652,13 +712,16 @@ defmodule Mix.Tasks.Test do
652712
end
653713
end
654714

655-
defp require_test_helper(dir) do
715+
defp require_test_helper(shell, dir) do
656716
file = Path.join(dir, "test_helper.exs")
657717

658718
if File.exists?(file) do
659719
Code.require_file(file)
660720
else
661-
Mix.raise("Cannot run tests because test helper file #{inspect(file)} does not exist")
721+
raise_with_shell(
722+
shell,
723+
"Cannot run tests because test helper file #{inspect(file)} does not exist"
724+
)
662725
end
663726
end
664727

lib/mix/test/mix/tasks/test_test.exs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,35 @@ defmodule Mix.Tasks.TestTest do
268268
end
269269
end
270270

271+
describe "--partitions" do
272+
test "splits tests into partitions" do
273+
in_fixture("test_stale", fn ->
274+
assert mix(["test", "--partitions", "3"], [{"MIX_TEST_PARTITION", "1"}]) =~
275+
"1 test, 0 failures"
276+
277+
assert mix(["test", "--partitions", "3"], [{"MIX_TEST_PARTITION", "2"}]) =~
278+
"1 test, 0 failures"
279+
280+
assert mix(["test", "--partitions", "3"], [{"MIX_TEST_PARTITION", "3"}]) =~
281+
"There are no tests to run"
282+
end)
283+
end
284+
285+
test "raises when no partition is given even with Mix.shell() change" do
286+
in_fixture("test_stale", fn ->
287+
File.write!("test/test_helper.exs", """
288+
Mix.shell(Mix.Shell.Process)
289+
ExUnit.start()
290+
""")
291+
292+
assert_run_output(
293+
["--partitions", "4"],
294+
"The MIX_TEST_PARTITION environment variable must be set"
295+
)
296+
end)
297+
end
298+
end
299+
271300
describe "logs and errors" do
272301
test "logs test absence for a project with no test paths" do
273302
in_fixture("test_stale", fn ->

0 commit comments

Comments
 (0)