Skip to content

Commit c889207

Browse files
committed
Add utility for loading and randomizing fixtures
1 parent 4cb46ad commit c889207

File tree

3 files changed

+276
-0
lines changed

3 files changed

+276
-0
lines changed

lib/together/test/fixtures.ex

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
defmodule Together.Test.Fixtures do
2+
@moduledoc """
3+
Helpers for reading and using JSON test fixtures
4+
5+
This module provides functions to read JSON fixtures from the `fixture` directory, decode them,
6+
and randomize any IDs present to avoid database deadlocks in async tests. Randomized IDs are
7+
consistent within the same test process, even across multiple fixture files.
8+
9+
Randomization is done with the following rules:
10+
11+
* The key must be named `id`, end with `_id`, or be specified in the `:keys` option.
12+
* Values that are `null` are left unchanged.
13+
* Numeric IDs are replaced with a unique positive integer using `System.unique_integer/1`.
14+
* UUIDs are replaced with a new UUID using `Ecto.UUID.generate/0`.
15+
* Other values are replaced with a string prefixed with `gen_` and a new UUID.
16+
17+
## Configuration
18+
19+
To reduce boilerplate, you can set the base path for fixtures in your `config/config.exs`:
20+
21+
config :together, fixture_path: "test/fixtures"
22+
23+
## Example
24+
25+
defmodule MyApp.Test.MyTest do
26+
use ExUnit.Case, async: true
27+
alias Together.Test.Fixtures
28+
29+
test "example test" do
30+
fixture = Fixtures.load("my_fixture.json")
31+
# ...
32+
end
33+
end
34+
"""
35+
36+
@id_map_dictionary_key :tg_test_fixture_id_map
37+
@uuid_re ~r/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/
38+
39+
@doc """
40+
Read and decode a JSON fixture from the given path, then randomize any IDs present
41+
42+
Matching IDs within the same file will continue to match with different values. Doing this helps
43+
to prevent database deadlocks that can occur when async tests use fixtures with the same IDs.
44+
45+
## Options
46+
47+
* `keys`: List of additional keys to randomize. By default, only keys named `id` and those
48+
ending with `_id` will be randomized.
49+
50+
"""
51+
@spec load(String.t(), keyword) :: any
52+
def load(path, opts \\ []) do
53+
base_path = Application.get_env(:together, :fixture_path, "")
54+
55+
Path.join(base_path, path)
56+
|> File.read!()
57+
|> JSON.decode!()
58+
|> randomize(opts[:keys] || [])
59+
end
60+
61+
@doc false
62+
@spec randomize(any, [String.t()]) :: any
63+
def randomize(value, extra_keys) do
64+
id_map = Process.get(@id_map_dictionary_key, %{})
65+
{id_map, value} = randomize(id_map, value, extra_keys)
66+
Process.put(@id_map_dictionary_key, id_map)
67+
68+
value
69+
end
70+
71+
@spec randomize(map, any, [String.t()]) :: {map, any}
72+
defp randomize(id_map, map_value, extra_keys) when is_map(map_value) do
73+
Enum.reduce(map_value, {id_map, %{}}, fn
74+
{key, value}, {id_map, modified_map} ->
75+
if key == "id" or String.ends_with?(key, "_id") or key in extra_keys do
76+
cond do
77+
is_nil(value) ->
78+
{id_map, modified_map}
79+
80+
is_integer(value) ->
81+
new_id = Map.get_lazy(id_map, value, fn -> System.unique_integer([:positive]) end)
82+
{Map.put(id_map, value, new_id), Map.put(modified_map, key, new_id)}
83+
84+
is_binary(value) ->
85+
case Regex.scan(@uuid_re, value) do
86+
[] ->
87+
new_id = Map.get_lazy(id_map, value, fn -> "gen_" <> Ecto.UUID.generate() end)
88+
{Map.put(id_map, value, new_id), Map.put(modified_map, key, new_id)}
89+
90+
matches ->
91+
{id_map, modified_value} =
92+
Enum.reduce(matches, {id_map, value}, fn [match], {id_map, modified_value} ->
93+
new_id = Map.get_lazy(id_map, match, fn -> Ecto.UUID.generate() end)
94+
95+
{Map.put(id_map, match, new_id),
96+
String.replace(modified_value, match, new_id)}
97+
end)
98+
99+
{id_map, Map.put(modified_map, key, modified_value)}
100+
end
101+
102+
:else ->
103+
{id_map, modified_map}
104+
end
105+
else
106+
{id_map, modified_value} = randomize(id_map, value, extra_keys)
107+
{id_map, Map.put(modified_map, key, modified_value)}
108+
end
109+
end)
110+
end
111+
112+
defp randomize(id_map, list_value, extra_keys) when is_list(list_value) do
113+
{id_map, list_value} =
114+
Enum.reduce(list_value, {id_map, []}, fn list_element, {id_map, modified_list} ->
115+
{id_map, modified_list_element} = randomize(id_map, list_element, extra_keys)
116+
{id_map, [modified_list_element | modified_list]}
117+
end)
118+
119+
{id_map, Enum.reverse(list_value)}
120+
end
121+
122+
defp randomize(id_map, value, _extra_keys), do: {id_map, value}
123+
end

test/fixture/example.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"id": 12345,
3+
"other": "data",
4+
"nested": {
5+
"related_id": "f91dea2c-14e4-4ac2-a09a-f451b14e1f36"
6+
},
7+
"related": {
8+
"id": "f91dea2c-14e4-4ac2-a09a-f451b14e1f36"
9+
},
10+
"stytch_style_id": "organization-test-1917c469-dc9e-487d-a019-b15e36cb5cae",
11+
"null_id": null,
12+
"custom": 67890
13+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
defmodule Together.Test.FixturesTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Together.Test.Fixtures
5+
6+
@uuid_re ~r/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/
7+
8+
describe "load/2" do
9+
test "loads and randomizes a fixture" do
10+
output = Fixtures.load("test/fixture/example.json", keys: ["custom"])
11+
12+
assert output["id"] != 12345
13+
assert is_integer(output["id"])
14+
assert output["id"] > 0
15+
16+
assert output["other"] == "data"
17+
18+
assert output["nested"]["related_id"] != "f91dea2c-14e4-4ac2-a09a-f451b14e1f36"
19+
assert output["nested"]["related_id"] =~ @uuid_re
20+
21+
assert output["related"]["id"] != "f91dea2c-14e4-4ac2-a09a-f451b14e1f36"
22+
assert output["related"]["id"] =~ @uuid_re
23+
assert output["related"]["id"] == output["nested"]["related_id"]
24+
25+
assert output["stytch_style_id"] != "organization-test-1917c469-dc9e-487d-a019-b15e36cb5cae"
26+
27+
assert output["stytch_style_id"] =~
28+
~r/organization-test-[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/
29+
30+
assert is_nil(output["null_id"])
31+
32+
assert output["custom"] != 12345
33+
assert is_integer(output["custom"])
34+
assert output["custom"] > 0
35+
end
36+
end
37+
38+
describe "randomize/2" do
39+
test "randomizes integer IDs" do
40+
input = %{
41+
"id" => 12345,
42+
"another_id" => 67890
43+
}
44+
45+
output = Fixtures.randomize(input, [])
46+
47+
assert output["id"] != 12345
48+
assert is_integer(output["id"])
49+
assert output["id"] > 0
50+
51+
assert output["another_id"] != 67890
52+
assert is_integer(output["another_id"])
53+
assert output["another_id"] > 0
54+
end
55+
56+
test "randomizes UUIDs" do
57+
input = %{
58+
"id" => "1d6f6021-150e-4839-8e06-edf50fac387b",
59+
"another_id" => "93ddb2fa-b3af-46dc-abbc-a5d5990819e6"
60+
}
61+
62+
output = Fixtures.randomize(input, [])
63+
64+
assert output["id"] != "1d6f6021-150e-4839-8e06-edf50fac387b"
65+
assert output["id"] =~ @uuid_re
66+
67+
assert output["another_id"] != "93ddb2fa-b3af-46dc-abbc-a5d5990819e6"
68+
assert output["another_id"] =~ @uuid_re
69+
end
70+
71+
test "randomizes UUID substrings" do
72+
input = %{
73+
"custom_id" =>
74+
"multiple-7d85dc07-0d75-4f5c-b9dd-4f7a7a6612c7-ids-93ddb2fa-b3af-46dc-abbc-a5d5990819e6",
75+
"another_id" => "93ddb2fa-b3af-46dc-abbc-a5d5990819e6"
76+
}
77+
78+
output = Fixtures.randomize(input, [])
79+
80+
assert output["another_id"] != "93ddb2fa-b3af-46dc-abbc-a5d5990819e6"
81+
assert output["another_id"] =~ @uuid_re
82+
83+
refute String.contains?(output["custom_id"], "7d85dc07-0d75-4f5c-b9dd-4f7a7a6612c7")
84+
refute String.contains?(output["custom_id"], "93ddb2fa-b3af-46dc-abbc-a5d5990819e6")
85+
86+
assert output["custom_id"] =~
87+
~r/multiple-[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}-ids-[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/
88+
89+
assert String.ends_with?(output["custom_id"], output["another_id"])
90+
end
91+
92+
test "mirrors matching IDs in the same file" do
93+
input = %{
94+
"id" => 12345,
95+
"mirror_id" => 12345,
96+
"my_id" => "1d6f6021-150e-4839-8e06-edf50fac387b",
97+
"mirror_my_id" => "1d6f6021-150e-4839-8e06-edf50fac387b"
98+
}
99+
100+
output = Fixtures.randomize(input, [])
101+
102+
assert output["id"] != 12345
103+
assert output["mirror_id"] == output["id"]
104+
105+
assert output["my_id"] != "1d6f6021-150e-4839-8e06-edf50fac387b"
106+
assert output["mirror_my_id"] == output["my_id"]
107+
end
108+
109+
test "mirrors matching IDs across files" do
110+
input1 = %{
111+
"id" => 12345,
112+
"mirror_id" => 67890
113+
}
114+
115+
input2 = %{
116+
"id" => 67890,
117+
"mirror_id" => 12345
118+
}
119+
120+
output1 = Fixtures.randomize(input1, [])
121+
output2 = Fixtures.randomize(input2, [])
122+
123+
assert output2["mirror_id"] == output1["id"]
124+
assert output1["mirror_id"] == output2["id"]
125+
end
126+
127+
test "does not modify non-ID keys" do
128+
input = %{
129+
"id" => 12345,
130+
"name" => "Test",
131+
"description" => "This is a test"
132+
}
133+
134+
output = Fixtures.randomize(input, [])
135+
136+
assert output["name"] == "Test"
137+
assert output["description"] == "This is a test"
138+
end
139+
end
140+
end

0 commit comments

Comments
 (0)