Skip to content

Commit 423ae9b

Browse files
authored
Merge pull request #18 from phoenixframework/fix-mutation-exunit-isolation
Isolate mutation targets from the independent checker gate
2 parents 5a3ab53 + 035bb85 commit 423ae9b

3 files changed

Lines changed: 145 additions & 4 deletions

File tree

‎mix.exs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ defmodule Group.MixProject do
3535
end
3636

3737
def cli do
38-
[preferred_envs: ["test.soak": :test]]
38+
[preferred_envs: ["test.exunit": :test, "test.soak": :test]]
3939
end
4040

4141
defp deps do
@@ -65,6 +65,9 @@ defmodule Group.MixProject do
6565
defp aliases do
6666
[
6767
test: ["test", "cmd test/jepsen/checker.sh", "cmd test/jepsen/lein.sh test :capture"],
68+
# Mutation targets must measure ExUnit, not the independent JVM gate.
69+
# Invoke the task module directly so the `test` alias is not expanded.
70+
"test.exunit": [&Mix.Tasks.Test.run/1],
6871
"test.soak": [
6972
# Run the PR gate in a child VM. test_helper starts distribution, and
7073
# keeping that VM alive for the following `cmd` phases can retain a

‎test/mutation/isolation_test.exs‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# Run with: elixir test/mutation/isolation_test.exs
2+
# Exercises the actual campaign against a tiny dependency-free fixture, with
3+
# the real Mix aliases and one real mutation definition. No peers/JVM/Docker.
4+
ExUnit.start()
5+
6+
defmodule Group.MutationIsolationTest do
7+
use ExUnit.Case, async: false
8+
9+
@repo Path.expand("../..", __DIR__)
10+
@mutation "drain_oversized_ingress_batch_without_yield"
11+
12+
setup do
13+
work = Path.join(@repo, "tmp/mutation-isolation-#{System.unique_integer([:positive])}")
14+
File.mkdir_p!(work)
15+
on_exit(fn -> File.rm_rf!(work) end)
16+
17+
# Keep aliases and preferred environments identical to the real project,
18+
# but remove application code/dependencies unrelated to this harness probe.
19+
ast = @repo |> Path.join("mix.exs") |> File.read!() |> Code.string_to_quoted!()
20+
21+
{_, functions} =
22+
Macro.prewalk(ast, [], fn
23+
{kind, _, [{name, _, _}, _]} = node, acc
24+
when kind in [:def, :defp] and name in [:aliases, :cli] ->
25+
{node, [Macro.to_string(node) | acc]}
26+
27+
node, acc ->
28+
{node, acc}
29+
end)
30+
31+
write(work, "mix.exs", """
32+
defmodule Isolation.MixProject do
33+
use Mix.Project
34+
def project, do: [app: :isolation, version: "0.0.0", aliases: aliases()]
35+
#{Enum.join(functions, "\n")}
36+
end
37+
""")
38+
39+
File.mkdir_p!(Path.join(work, "deps"))
40+
File.cp!(Path.join(@repo, "test/mutation/run.exs"), write(work, "test/mutation/run.exs", ""))
41+
write(work, "test/test_helper.exs", "ExUnit.start()\n")
42+
43+
checker =
44+
write(work, "test/jepsen/checker.sh", """
45+
#!/bin/sh
46+
echo invoked >> "#{work}/checker-invocations"
47+
exit 73
48+
""")
49+
50+
File.chmod!(checker, 0o755)
51+
52+
write(work, "lib/group/replica.ex", """
53+
defmodule Isolation.Target do
54+
@incoming_batch_quota 1
55+
def split(messages) do
56+
{turn, remaining} = Enum.split(messages, @incoming_batch_quota)
57+
{turn, remaining}
58+
end
59+
end
60+
""")
61+
62+
{:ok, work: work}
63+
end
64+
65+
test "a passing target survives a broken checker; ordinary mix test still runs it", %{
66+
work: work
67+
} do
68+
target(work, "assert is_tuple(Isolation.Target.split([1, 2]))")
69+
{output, status} = campaign(work)
70+
assert status == 1, output
71+
assert output =~ "#{@mutation}: SURVIVED"
72+
refute File.exists?(Path.join(work, "checker-invocations"))
73+
74+
{output, status} = command(work, ["mix", "test", "test/group_test.exs:34"])
75+
assert status != 0, output
76+
assert File.read!(Path.join(work, "checker-invocations")) == "invoked\n"
77+
end
78+
79+
test "a regression assertion kills the mutant", %{work: work} do
80+
target(work, "assert Isolation.Target.split([1, 2]) == {[1], [2]}")
81+
{output, status} = campaign(work)
82+
assert status == 0, output
83+
assert output =~ "#{@mutation}: killed"
84+
refute File.exists?(Path.join(work, "checker-invocations"))
85+
end
86+
87+
test "a failing baseline fails the campaign before mutation", %{work: work} do
88+
target(work, "assert false")
89+
{output, status} = campaign(work)
90+
assert status != 0, output
91+
assert output =~ "baseline failed"
92+
refute output =~ "#{@mutation}: killed"
93+
end
94+
95+
test "a noncompiling mutant remains invalid, not killed", %{work: work} do
96+
target(work, "assert is_tuple(Isolation.Target.split([1, 2]))")
97+
path = Path.join(work, "test/mutation/run.exs")
98+
source = File.read!(path)
99+
100+
# Only the fixture's chosen replacement is made syntactically invalid.
101+
old = ~S(" _ = @incoming_batch_quota\n turn = messages\n remaining = []")
102+
assert length(:binary.matches(source, old)) == 1
103+
File.write!(path, String.replace(source, old, ~S(" this will not compile(")))
104+
105+
{output, status} = campaign(work)
106+
assert status != 0, output
107+
assert output =~ "#{@mutation}: INVALID (does not compile)"
108+
refute output =~ "#{@mutation}: killed"
109+
end
110+
111+
defp target(work, assertion) do
112+
write(
113+
work,
114+
"test/group_test.exs",
115+
"defmodule Isolation.TargetTest do\n use ExUnit.Case\n" <>
116+
String.duplicate("\n", 31) <>
117+
" test \"target\" do\n #{assertion}\n end\nend\n"
118+
)
119+
end
120+
121+
defp campaign(work), do: command(work, ["elixir", "test/mutation/run.exs", @mutation])
122+
123+
defp command(work, args) do
124+
timeout = System.find_executable("timeout") || raise "timeout is required"
125+
126+
System.cmd(timeout, ["45" | args],
127+
cd: work,
128+
env: [{"ERL_FLAGS", "+S 2:2"}, {"MIX_ENV", nil}, {"ERL_AFLAGS", nil}],
129+
stderr_to_stdout: true
130+
)
131+
end
132+
133+
defp write(work, relative, content) do
134+
path = Path.join(work, relative)
135+
File.mkdir_p!(Path.dirname(path))
136+
File.write!(path, content)
137+
path
138+
end
139+
end

‎test/mutation/run.exs‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,11 +1166,10 @@ defmodule Group.MutationCampaign do
11661166
end
11671167

11681168
defp run_test(directory, test) do
1169-
run_with_timeout(directory, ["mix", "test" | test],
1169+
run_with_timeout(directory, ["mix", "test.exunit" | test],
11701170
env: [
11711171
{"GROUP_MODEL_RUNS", "1"},
1172-
{"GROUP_MODEL_COMMANDS", "8"},
1173-
{"GROUP_JEPSEN_SKIP_CHECKER", "1"}
1172+
{"GROUP_MODEL_COMMANDS", "8"}
11741173
]
11751174
)
11761175
end

0 commit comments

Comments
 (0)