|
| 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 |
0 commit comments