Repository: PyVRP/PyVRP
Pull request #372 (2023-09-01 10:03:11)

Add test fixtures for instance reading
This PR adds fixtures for a few common instances we often use in tests. That simplifies the tests a little bit.

<details>

**Notes**:

- It is essential that you add a test when making code changes.
  This keeps the code coverage level up, and helps ensure the changes work as intended.
- When fixing a bug, you must add a test that would produce the bug in the master branch, and then show that it is fixed with the new code. 
- New code additions must be well formatted. Changes should pass the pre-commit workflow, which you can set up locally using [pre-commit](https://pre-commit.com/#intro). 
- Docstring additions must render correctly, including escapes and LaTeX.

</details>



Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 795168d..539592d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -110,7 +110,7 @@ ignore_missing_imports = true
 
 
 [tool.pytest.ini_options]
-addopts = "--cov=. --cov-report=xml --cov-report=term"
+addopts = "--cov=. --cov-report=xml --cov-report=term -p pyvrp.tests.fixtures"
 testpaths = "pyvrp"
 
 


Test patch:
diff --git a/pyvrp/crossover/tests/test_selective_route_exchange.py b/pyvrp/crossover/tests/test_selective_route_exchange.py
index d272e33..dc35a92 100644
--- a/pyvrp/crossover/tests/test_selective_route_exchange.py
+++ b/pyvrp/crossover/tests/test_selective_route_exchange.py
@@ -12,45 +12,43 @@ from pyvrp import (
 )
 from pyvrp.crossover import selective_route_exchange as srex
 from pyvrp.crossover._crossover import selective_route_exchange as cpp_srex
-from pyvrp.tests.helpers import make_heterogeneous, read
+from pyvrp.tests.helpers import make_heterogeneous
 
 
-def test_same_parents_same_offspring():
+def test_same_parents_same_offspring(ok_small):
     """
     Tests that SREX produces identical offspring when both parents are the
     same.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
-    solution = Solution(data, [[1, 2], [3, 4]])
-    offspring = srex((solution, solution), data, cost_evaluator, rng)
+    solution = Solution(ok_small, [[1, 2], [3, 4]])
+    offspring = srex((solution, solution), ok_small, cost_evaluator, rng)
 
     assert_equal(offspring, solution)
 
 
-def test_srex_empty_solution():
+def test_srex_empty_solution(prize_collecting):
     """
     Tests that SREX returns the other parent when one of the solutions is
     empty. This can occur during prize collecting, and in that case there are
     no routes to exchange.
     """
-    data = read("data/p06-2-50.vrp", round_func="dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
-    empty = Solution(data, [])
-    nonempty = Solution(data, [[1, 2, 3, 4]])
+    empty = Solution(prize_collecting, [])
+    nonempty = Solution(prize_collecting, [[1, 2, 3, 4]])
 
     # If both parents are empty the returned offspring must also be empty.
-    offspring = srex((empty, empty), data, cost_evaluator, rng)
+    offspring = srex((empty, empty), prize_collecting, cost_evaluator, rng)
     assert_equal(offspring, empty)
 
     # If one of the two parents is empty but the other is not, the returned
     # solution is the nonempty parent.
     for parents in [(empty, nonempty), (nonempty, empty)]:
-        offspring = srex(parents, data, cost_evaluator, rng)
+        offspring = srex(parents, prize_collecting, cost_evaluator, rng)
         assert_equal(offspring, nonempty)
 
 
@@ -63,43 +61,41 @@ def test_srex_empty_solution():
         (0, 0, 2),  # num_moved_routes > min(# routes first, # routes second)
     ],
 )
-def test_raise_invalid_arguments(idx1, idx2, num_moved_routes):
+def test_raise_invalid_arguments(ok_small, idx1, idx2, num_moved_routes):
     """
     Tests that SREX raises when some of the arguments are invalid.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
 
-    sol1 = Solution(data, [[1], [2], [3, 4]])
-    sol2 = Solution(data, [[1, 2, 3, 4]])
+    sol1 = Solution(ok_small, [[1], [2], [3, 4]])
+    sol2 = Solution(ok_small, [[1, 2, 3, 4]])
 
     with assert_raises(ValueError):
         cpp_srex(
             (sol1, sol2),
-            data,
+            ok_small,
             cost_evaluator,
             (idx1, idx2),
             num_moved_routes,
         )
 
 
-def test_srex_move_all_routes():
+def test_srex_move_all_routes(ok_small):
     """
     Tests if SREX produces an offspring that is identical to the second parent
     when all routes are replaced during crossover.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
 
-    sol1 = Solution(data, [[1], [2], [3, 4]])
-    sol2 = Solution(data, [[1, 2], [3], [4]])
-    offspring = cpp_srex((sol1, sol2), data, cost_evaluator, (0, 0), 3)
+    sol1 = Solution(ok_small, [[1], [2], [3, 4]])
+    sol2 = Solution(ok_small, [[1, 2], [3], [4]])
+    offspring = cpp_srex((sol1, sol2), ok_small, cost_evaluator, (0, 0), 3)
 
     # Note: result will be permuted but equality is invariant to that.
     assert_equal(offspring, sol2)
 
 
-def test_srex_sorts_routes():
+def test_srex_sorts_routes(ok_small):
     """
     Tests if SREX sorts the input before applying the operator.
 
@@ -108,60 +104,57 @@ def test_srex_sorts_routes():
     routes, SREX should be invariant to route permutations and always produce
     the exact same offspring.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
 
     routes1 = [[1], [2], [3, 4]]
     routes2 = [[1, 2], [3], [4]]
-    sol1 = Solution(data, routes1)
-    sol2 = Solution(data, routes2)
-    offspring = cpp_srex((sol1, sol2), data, cost_evaluator, (0, 0), 1)
+    sol1 = Solution(ok_small, routes1)
+    sol2 = Solution(ok_small, routes2)
+    offspring = cpp_srex((sol1, sol2), ok_small, cost_evaluator, (0, 0), 1)
 
     for permuted_routes1 in itertools.permutations(routes1):
         for permuted_routes2 in itertools.permutations(routes2):
-            sol1 = Solution(data, permuted_routes1)
-            sol2 = Solution(data, permuted_routes2)
+            sol1 = Solution(ok_small, permuted_routes1)
+            sol2 = Solution(ok_small, permuted_routes2)
             permuted_offspring = cpp_srex(
-                (sol1, sol2), data, cost_evaluator, (0, 0), 1
+                (sol1, sol2), ok_small, cost_evaluator, (0, 0), 1
             )
             assert_equal(permuted_offspring, offspring)
 
 
-def test_srex_does_not_reinsert_unplanned_clients():
+def test_srex_does_not_reinsert_unplanned_clients(ok_small):
     """
     Tests the case where the routes are not perfectly complementary. In that
     case, some clients will no longer in the solution after crossover: SREX
     does not reinsert those, but instead leaves that to the search method.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
 
     # Note that when routes are exchanged, some customers will necessarily be
     # removed from the solution because the routes are overlapping. The
     # offspring are thus incomplete.
-    sol1 = Solution(data, [[3, 4], [1, 2]])
-    sol2 = Solution(data, [[2, 3], [4, 1]])
+    sol1 = Solution(ok_small, [[3, 4], [1, 2]])
+    sol2 = Solution(ok_small, [[2, 3], [4, 1]])
 
     # The start indices do not change because there are no improving moves.
     # So, sol1's route [3, 4] will be replaced by sol2's route [2, 3].
     # This results in two incomplete offspring [[2, 3], [1]] and [[3], [1, 2]],
     # the first with penalised cost 7898, the second with cost 9495. Thus, the
     # first one is returned.
-    offspring = cpp_srex((sol1, sol2), data, cost_evaluator, (0, 0), 1)
-    expected = Solution(data, [[2, 3], [1]])
+    offspring = cpp_srex((sol1, sol2), ok_small, cost_evaluator, (0, 0), 1)
+    expected = Solution(ok_small, [[2, 3], [1]])
     assert_equal(offspring, expected)
     assert_(not expected.is_complete())
 
 
-def test_srex_changed_start_indices():
+def test_srex_changed_start_indices(ok_small):
     """
     Tests the case where the initial start indices are changed in SREX.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
 
-    sol1 = Solution(data, [[4], [1, 2, 3]])
-    sol2 = Solution(data, [[3], [1, 2, 4]])
+    sol1 = Solution(ok_small, [[4], [1, 2, 3]])
+    sol2 = Solution(ok_small, [[3], [1, 2, 4]])
 
     # We will start with idx1 = 1 and idx2 = 1 (1 for both solutions)
     # The difference for A to move left (= right) is -1. The difference for B
@@ -172,18 +165,18 @@ def test_srex_changed_start_indices():
     # This results in two candidate offspring, [[1, 2, 4], [3]] with cost
     # 10195, and [[4], [1, 2, 3]] with cost 31029. The first candidate is
     # returned since it has the lowest cost.
-    offspring = cpp_srex((sol1, sol2), data, cost_evaluator, (1, 1), 1)
-    expected = Solution(data, [[1, 2, 4], [3]])
+    offspring = cpp_srex((sol1, sol2), ok_small, cost_evaluator, (1, 1), 1)
+    expected = Solution(ok_small, [[1, 2, 4], [3]])
     assert_equal(offspring, expected)
 
 
-def test_srex_heterogeneous_changed_start_indices():
+def test_srex_heterogeneous_changed_start_indices(ok_small):
     """
     Tests the case where the initial start indices are changed in SREX for
     heterogeneous routes.
     """
     data = make_heterogeneous(
-        read("data/OkSmall.txt"), [VehicleType(10, 2), VehicleType(20, 1)]
+        ok_small, [VehicleType(10, 2), VehicleType(20, 1)]
     )
     cost_evaluator = CostEvaluator(20, 6)
 
@@ -221,16 +214,15 @@ def test_srex_heterogeneous_changed_start_indices():
     expected = [Route(data, [1, 2, 4], 0), Route(data, [3], 1)]
 
 
-def test_srex_a_right_move():
+def test_srex_a_right_move(ok_small):
     """
     Tests the case where the initial start indices are changed by moving the
     A index to the right.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
 
-    sol1 = Solution(data, [[4], [2], [1, 3]])
-    sol2 = Solution(data, [[3], [2], [4, 1]])
+    sol1 = Solution(ok_small, [[4], [2], [1, 3]])
+    sol2 = Solution(ok_small, [[3], [2], [4, 1]])
 
     # We describe the A-right case here in detail. The tests below for A-left,
     # B-left and B-right can be worked out similarly. Note that the test cases
@@ -261,59 +253,56 @@ def test_srex_a_right_move():
     # Candidate offspring
     # [4, 1] [2] [3] - cost: 12699 <-- selected as new offspring
     # [4] [2] [1, 3] - cost: 24416
-    offspring = cpp_srex((sol1, sol2), data, cost_evaluator, (2, 2), 1)
-    expected = Solution(data, [[4, 1], [2], [3]])
+    offspring = cpp_srex((sol1, sol2), ok_small, cost_evaluator, (2, 2), 1)
+    expected = Solution(ok_small, [[4, 1], [2], [3]])
     assert_equal(offspring, expected)
 
 
-def test_srex_a_left_move():
+def test_srex_a_left_move(ok_small):
     """
     Tests the case where the initial start indices are changed by moving to
     A index to the left.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
 
     # We create the routes sorted by angle such that SREX sorting doesn't
     # affect them.
-    sol1 = Solution(data, [[2], [1, 3], [4]])
-    sol2 = Solution(data, [[3], [2, 1], [4]])
+    sol1 = Solution(ok_small, [[2], [1, 3], [4]])
+    sol2 = Solution(ok_small, [[3], [2, 1], [4]])
 
-    offspring = cpp_srex((sol1, sol2), data, cost_evaluator, (2, 2), 1)
-    expected = Solution(data, [[4], [2, 1], [3]])
+    offspring = cpp_srex((sol1, sol2), ok_small, cost_evaluator, (2, 2), 1)
+    expected = Solution(ok_small, [[4], [2, 1], [3]])
     assert_equal(offspring, expected)
 
 
-def test_srex_b_left_move():
+def test_srex_b_left_move(ok_small):
     """
     Tests the case where the initial start indices are changed by moving the
     B index to the left.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
 
     # We create the routes sorted by angle such that SREX sorting doesn't
     # affect them.
-    sol1 = Solution(data, [[2], [1, 3], [4]])
-    sol2 = Solution(data, [[3], [4, 1], [2]])
-    offspring = cpp_srex((sol1, sol2), data, cost_evaluator, (1, 2), 1)
-    expected = Solution(data, [[4, 1], [2], [3]])
+    sol1 = Solution(ok_small, [[2], [1, 3], [4]])
+    sol2 = Solution(ok_small, [[3], [4, 1], [2]])
+    offspring = cpp_srex((sol1, sol2), ok_small, cost_evaluator, (1, 2), 1)
+    expected = Solution(ok_small, [[4, 1], [2], [3]])
     assert_equal(offspring, expected)
 
 
-def test_srex_b_right_move():
+def test_srex_b_right_move(ok_small):
     """
     Tests the case where the initial start indices are changed by moving the
     B index to the right.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
 
     # We create the routes sorted by angle such that SREX sorting doesn't
     # affect them.
-    sol1 = Solution(data, [[2], [1, 3], [4]])
-    sol2 = Solution(data, [[3], [2, 1], [4]])
+    sol1 = Solution(ok_small, [[2], [1, 3], [4]])
+    sol2 = Solution(ok_small, [[3], [2, 1], [4]])
 
-    offspring = cpp_srex((sol1, sol2), data, cost_evaluator, (2, 1), 1)
-    expected = Solution(data, [[4], [2], [1, 3]])
+    offspring = cpp_srex((sol1, sol2), ok_small, cost_evaluator, (2, 1), 1)
+    expected = Solution(ok_small, [[4], [2], [1, 3]])
     assert_equal(offspring, expected)
diff --git a/pyvrp/diversity/tests/test_broken_pairs_distance.py b/pyvrp/diversity/tests/test_broken_pairs_distance.py
index b6b47f1..beaf492 100644
--- a/pyvrp/diversity/tests/test_broken_pairs_distance.py
+++ b/pyvrp/diversity/tests/test_broken_pairs_distance.py
@@ -2,21 +2,18 @@ from numpy.testing import assert_allclose
 
 from pyvrp import Solution
 from pyvrp.diversity import broken_pairs_distance as bpd
-from pyvrp.tests.helpers import read
 
 
-def test_broken_pairs_distance():
+def test_broken_pairs_distance(ok_small):
     """
     Test broken pairs distance (BPD) calculations on a number of solutions to
     a small instance.
     """
-    data = read("data/OkSmall.txt")
-
-    sol1 = Solution(data, [[1, 2, 3, 4]])
-    sol2 = Solution(data, [[1, 2], [3], [4]])
-    sol3 = Solution(data, [[3], [4, 1, 2]])
-    sol4 = Solution(data, [[4, 3, 2, 1]])
-    sol5 = Solution(data, [[1, 2, 3]])
+    sol1 = Solution(ok_small, [[1, 2, 3, 4]])
+    sol2 = Solution(ok_small, [[1, 2], [3], [4]])
+    sol3 = Solution(ok_small, [[3], [4, 1, 2]])
+    sol4 = Solution(ok_small, [[4, 3, 2, 1]])
+    sol5 = Solution(ok_small, [[1, 2, 3]])
 
     # BPD of a solution and itself should be zero.
     for sol in [sol1, sol2, sol3, sol4]:
diff --git a/pyvrp/repair/tests/test_greedy_repair.py b/pyvrp/repair/tests/test_greedy_repair.py
index 59a1b41..07fa1b3 100644
--- a/pyvrp/repair/tests/test_greedy_repair.py
+++ b/pyvrp/repair/tests/test_greedy_repair.py
@@ -3,82 +3,77 @@ from numpy.testing import assert_, assert_equal, assert_raises
 
 from pyvrp import CostEvaluator, RandomNumberGenerator, Route, Solution
 from pyvrp.repair import greedy_repair
-from pyvrp.tests.helpers import read
 
 
-def test_raises_given_no_routes_and_unplanned_clients():
+def test_raises_given_no_routes_and_unplanned_clients(ok_small):
     """
     Tests that the greedy repair function raises when it's not given any routes
     to insert unplanned clients into. Greedy repair does not create new routes,
     so this is an impossible situation.
     """
-    data = read("data/OkSmall.txt")
     cost_eval = CostEvaluator(1, 1)
-    empty = Solution(data, [])
+    empty = Solution(ok_small, [])
 
     # This call should not raise since unplanned is empty: there are no routes
     # to insert into, which is OK since we have nothing to insert.
-    greedy_repair(empty, [], data, cost_eval)
+    greedy_repair(empty, [], ok_small, cost_eval)
 
     with assert_raises(ValueError):
         # But now we do need to insert a client, and that should raise.
-        greedy_repair(empty, [1], data, cost_eval)
+        greedy_repair(empty, [1], ok_small, cost_eval)
 
 
-def test_insert_into_empty_route():
+def test_insert_into_empty_route(ok_small):
     """
     Although greedy repair does not create *new* routes, existing empty routes
     will be used if they're available.
     """
-    data = read("data/OkSmall.txt")
     cost_eval = CostEvaluator(1, 1)
 
     # We want to insert client one into an empty route. That should result in
     # a solution that has a single route with just client 1.
-    routes = [Route(data, [], 0)]
-    greedy = greedy_repair(routes, [1], data, cost_eval)
-    assert_equal(greedy, Solution(data, [[1]]))
+    routes = [Route(ok_small, [], 0)]
+    greedy = greedy_repair(routes, [1], ok_small, cost_eval)
+    assert_equal(greedy, Solution(ok_small, [[1]]))
 
 
-def test_empty_routes_or_unplanned_is_a_no_op():
+def test_empty_routes_or_unplanned_is_a_no_op(ok_small):
     """
     If there are no routes, or no unplanned clients, then the returned solution
     should be the same as the one that was given as an argument.
     """
-    data = read("data/OkSmall.txt")
     cost_eval = CostEvaluator(1, 1)
 
     # When unplanned is empty, there is nothing for greedy repair to do, so it
     # should return the exact same solution it received.
-    sol = Solution(data, [[3, 2], [1, 4]])
-    assert_equal(greedy_repair(sol, [], data, cost_eval), sol)
+    sol = Solution(ok_small, [[3, 2], [1, 4]])
+    assert_equal(greedy_repair(sol, [], ok_small, cost_eval), sol)
 
     # This is also true when the solution is not complete: greedy repair only
     # reinserts what's in unplanned.
-    sol = Solution(data, [[2, 3, 4]])
+    sol = Solution(ok_small, [[2, 3, 4]])
     assert_(not sol.is_complete())
-    assert_equal(greedy_repair(sol, [], data, cost_eval), sol)
+    assert_equal(greedy_repair(sol, [], ok_small, cost_eval), sol)
 
     # Finally, when both the set of routes and the list of unplanned clients
     # is empty, we get an empty solution.
-    assert_equal(greedy_repair([], [], data, cost_eval), Solution(data, []))
+    assert_equal(greedy_repair([], [], ok_small, cost_eval).num_clients(), 0)
 
 
-def test_after_depot():
+def test_after_depot(ok_small):
     """
     Tests moves where it is optimal to insert directly after the depot.
     """
-    data = read("data/OkSmall.txt")
     cost_eval = CostEvaluator(1, 1)
 
     # We want to insert client 4 into the following single-route solution. It
     # is optimal to do so directly after the depot, just before client 3.
-    sol = Solution(data, [[3, 2, 1]])
+    sol = Solution(ok_small, [[3, 2, 1]])
     unplanned = [4]
 
     # The greedy repair operator inserts into *existing* routes; it does not
     # create new ones.
-    repaired = greedy_repair(sol, unplanned, data, cost_eval)
+    repaired = greedy_repair(sol, unplanned, ok_small, cost_eval)
     assert_equal(sol.num_routes(), repaired.num_routes())
 
     # Let's check if the repaired solution indeed visits client 4 first.
@@ -86,48 +81,47 @@ def test_after_depot():
     assert_equal(route.visits(), [4, 3, 2, 1])
 
 
-def test_OkSmall():
+def test_OkSmall(ok_small):
     """
     Tests greedy repair on a small instance.
     """
-    data = read("data/OkSmall.txt")
     cost_eval = CostEvaluator(1, 1)
 
     # We want to insert 1 and 4 into this solution. Both 1 and 4 are close to
     # 3, so it would be cheapest to insert these into the second route, as
     # 1 -> 3 -> 4.
-    sol = Solution(data, [[2], [3]])
+    sol = Solution(ok_small, [[2], [3]])
     unplanned = [1, 4]
 
-    repaired = greedy_repair(sol, unplanned, data, cost_eval)
-    expected = Solution(data, [[2], [4, 3, 1]])
+    repaired = greedy_repair(sol, unplanned, ok_small, cost_eval)
+    expected = Solution(ok_small, [[2], [4, 3, 1]])
     assert_equal(repaired, expected)
 
 
 @pytest.mark.parametrize("seed", [0, 13, 42])
-def test_RC208(seed: int):
+def test_RC208(rc208, seed: int):
     """
     This smoke test checks that greedy repair is better than random on a larger
     instance, for several seeds.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
-    assert_(data.num_vehicles < data.num_clients)
+    assert_(rc208.num_vehicles < rc208.num_clients)
 
     # Let's first create a random solution that uses all vehicles.
     rng = RandomNumberGenerator(seed=seed)
-    random = Solution.make_random(data, rng)
-    assert_equal(random.num_routes(), data.num_vehicles)
+    random = Solution.make_random(rc208, rng)
+    assert_equal(random.num_routes(), rc208.num_vehicles)
 
     # Let's next create the solution we want to repair. To ensure we use the
     # same number of vehicles, we initialise this solution with dummy routes.
-    to_repair = Solution(data, [[idx + 1] for idx in range(data.num_vehicles)])
+    routes = [[idx + 1] for idx in range(rc208.num_vehicles)]
+    to_repair = Solution(rc208, routes)
 
     cost_eval = CostEvaluator(1, 1)
-    unplanned = list(range(data.num_vehicles + 1, data.num_clients + 1))
+    unplanned = list(range(rc208.num_vehicles + 1, rc208.num_clients + 1))
 
     # Greedily repair the solution by inserting all clients that are not
     # already in the dummy routes.
-    greedy = greedy_repair(to_repair, unplanned, data, cost_eval)
+    greedy = greedy_repair(to_repair, unplanned, rc208, cost_eval)
 
     # The greedy solution should be (quite a bit) better than random.
     random_cost = cost_eval.penalised_cost(random)
diff --git a/pyvrp/search/tests/test_Exchange.py b/pyvrp/search/tests/test_Exchange.py
index f704a2e..b4f66f6 100644
--- a/pyvrp/search/tests/test_Exchange.py
+++ b/pyvrp/search/tests/test_Exchange.py
@@ -26,7 +26,7 @@ from pyvrp.search import (
     compute_neighbours,
 )
 from pyvrp.search._search import Node, Route
-from pyvrp.tests.helpers import make_heterogeneous, read
+from pyvrp.tests.helpers import make_heterogeneous
 
 
 @mark.parametrize(
@@ -40,22 +40,21 @@ from pyvrp.tests.helpers import make_heterogeneous, read
         Exchange33,
     ],
 )
-def test_swap_single_route_stays_single_route(operator):
+def test_swap_single_route_stays_single_route(rc208, operator):
     """
     Swap operators ((N, M)-exchange operators with M > 0) on a single route can
     only move within the same route, so they can never find a solution that has
     more than one route.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
-    nb_params = NeighbourhoodParams(nb_granular=data.num_clients)
-    ls = LocalSearch(data, rng, compute_neighbours(data, nb_params))
-    ls.add_node_operator(operator(data))
+    nb_params = NeighbourhoodParams(nb_granular=rc208.num_clients)
+    ls = LocalSearch(rc208, rng, compute_neighbours(rc208, nb_params))
+    ls.add_node_operator(operator(rc208))
 
-    single_route = list(range(1, data.num_clients + 1))
-    sol = Solution(data, [single_route])
+    single_route = list(range(1, rc208.num_clients + 1))
+    sol = Solution(rc208, [single_route])
     improved_sol = ls.search(sol, cost_evaluator)
 
     # The new solution should strictly improve on our original solution.
@@ -65,29 +64,21 @@ def test_swap_single_route_stays_single_route(operator):
     assert_(improved_cost < current_cost)
 
 
-@mark.parametrize(
-    "operator",
-    [
-        Exchange10,
-        Exchange20,
-        Exchange30,
-    ],
-)
-def test_relocate_uses_empty_routes(operator):
+@mark.parametrize("operator", [Exchange10, Exchange20, Exchange30])
+def test_relocate_uses_empty_routes(rc208, operator):
     """
     Unlike the swapping exchange operators, relocate should be able to relocate
     clients to empty routes if that is an improvement.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
-    nb_params = NeighbourhoodParams(nb_granular=data.num_clients)
-    ls = LocalSearch(data, rng, compute_neighbours(data, nb_params))
-    ls.add_node_operator(operator(data))
+    nb_params = NeighbourhoodParams(nb_granular=rc208.num_clients)
+    ls = LocalSearch(rc208, rng, compute_neighbours(rc208, nb_params))
+    ls.add_node_operator(operator(rc208))
 
-    single_route = list(range(1, data.num_clients + 1))
-    sol = Solution(data, [single_route])
+    single_route = list(range(1, rc208.num_clients + 1))
+    sol = Solution(rc208, [single_route])
     improved_sol = ls.search(sol, cost_evaluator)
 
     # The new solution should strictly improve on our original solution, and
@@ -108,85 +99,81 @@ def test_relocate_uses_empty_routes(operator):
         Exchange33,
     ],
 )
-def test_cannot_exchange_when_parts_overlap_with_depot(operator):
+def test_cannot_exchange_when_parts_overlap_with_depot(ok_small, operator):
     """
     (N, M)-exchange works by exchanging N nodes starting at some node U with
     M nodes starting at some node V. But when there is no sequence of N or M
     nodes that does not contain the depot (because the routes are very short),
     then no exchange is possible.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
-    nb_params = NeighbourhoodParams(nb_granular=data.num_clients)
-    ls = LocalSearch(data, rng, compute_neighbours(data, nb_params))
-    ls.add_node_operator(operator(data))
+    nb_params = NeighbourhoodParams(nb_granular=ok_small.num_clients)
+    ls = LocalSearch(ok_small, rng, compute_neighbours(ok_small, nb_params))
+    ls.add_node_operator(operator(ok_small))
 
-    sol = Solution(data, [[1, 2], [3], [4]])
+    sol = Solution(ok_small, [[1, 2], [3], [4]])
     new_sol = ls.search(sol, cost_evaluator)
 
     assert_equal(new_sol, sol)
 
 
 @mark.parametrize("operator", [Exchange32, Exchange33])
-def test_cannot_exchange_when_segments_overlap(operator):
+def test_cannot_exchange_when_segments_overlap(ok_small, operator):
     """
     (3, 2)- and (3, 3)-exchange cannot exchange anything on a length-four
     single route solution: there's always overlap between the segments.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
-    nb_params = NeighbourhoodParams(nb_granular=data.num_clients)
-    ls = LocalSearch(data, rng, compute_neighbours(data, nb_params))
-    ls.add_node_operator(operator(data))
+    nb_params = NeighbourhoodParams(nb_granular=ok_small.num_clients)
+    ls = LocalSearch(ok_small, rng, compute_neighbours(ok_small, nb_params))
+    ls.add_node_operator(operator(ok_small))
 
-    sol = Solution(data, [[1, 2, 3, 4]])
+    sol = Solution(ok_small, [[1, 2, 3, 4]])
     new_sol = ls.search(sol, cost_evaluator)
 
     assert_equal(new_sol, sol)
 
 
-def test_cannot_swap_adjacent_segments():
+def test_cannot_swap_adjacent_segments(ok_small):
     """
     (2, 2)-exchange on a single route cannot swap adjacent segments, since
     that's already covered by (2, 0)-exchange.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
-    nb_params = NeighbourhoodParams(nb_granular=data.num_clients)
-    ls = LocalSearch(data, rng, compute_neighbours(data, nb_params))
-    ls.add_node_operator(Exchange22(data))
+    nb_params = NeighbourhoodParams(nb_granular=ok_small.num_clients)
+    ls = LocalSearch(ok_small, rng, compute_neighbours(ok_small, nb_params))
+    ls.add_node_operator(Exchange22(ok_small))
 
     # An adjacent swap by (2, 2)-exchange could have created the single-route
     # solution [3, 4, 1, 2], which has a much lower cost. But that's not
     # allowed because adjacent swaps are not allowed.
-    sol = Solution(data, [[1, 2, 3, 4]])
+    sol = Solution(ok_small, [[1, 2, 3, 4]])
     new_sol = ls.search(sol, cost_evaluator)
 
     assert_equal(new_sol, sol)
 
 
-def test_swap_between_routes_OkSmall():
+def test_swap_between_routes_OkSmall(ok_small):
     """
     On the OkSmall example, (2, 1)-exchange should be able to swap parts of a
     two route solution, resulting in something better.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
-    nb_params = NeighbourhoodParams(nb_granular=data.num_clients)
-    ls = LocalSearch(data, rng, compute_neighbours(data, nb_params))
-    ls.add_node_operator(Exchange21(data))
+    nb_params = NeighbourhoodParams(nb_granular=ok_small.num_clients)
+    ls = LocalSearch(ok_small, rng, compute_neighbours(ok_small, nb_params))
+    ls.add_node_operator(Exchange21(ok_small))
 
-    sol = Solution(data, [[1, 2], [3, 4]])
+    sol = Solution(ok_small, [[1, 2], [3, 4]])
     improved_sol = ls.search(sol, cost_evaluator)
-    expected = Solution(data, [[3, 4, 2], [1]])
+    expected = Solution(ok_small, [[3, 4, 2], [1]])
     assert_equal(improved_sol, expected)
 
     current_cost = cost_evaluator.penalised_cost(sol)
@@ -194,18 +181,17 @@ def test_swap_between_routes_OkSmall():
     assert_(improved_cost < current_cost)
 
 
-def test_relocate_after_depot_should_work():
+def test_relocate_after_depot_should_work(ok_small):
     """
     This test exercises the bug identified in issue #142, involving a relocate
     action that should insert directly after the depot.
     """
-    data = read("data/OkSmall.txt")
-    op = Exchange10(data)
+    op = Exchange10(ok_small)
 
     # We create two routes: one with clients [1, 2, 3], and the other empty.
     # It is an improving move to insert client 3 into the empty route.
-    route1 = Route(data, idx=0, vehicle_type=0)
-    route2 = Route(data, idx=1, vehicle_type=0)
+    route1 = Route(ok_small, idx=0, vehicle_type=0)
+    route2 = Route(ok_small, idx=1, vehicle_type=0)
 
     nodes = [Node(loc=client) for client in [1, 2, 3]]
     for node in nodes:
@@ -283,13 +269,14 @@ def test_relocate_only_happens_when_distance_and_duration_allow_it():
     assert_equal(ls.search(distance_optimal, cost_evaluator), duration_optimal)
 
 
-def test_relocate_to_heterogeneous_empty_route():
+def test_relocate_to_heterogeneous_empty_route(ok_small):
     """
     This test asserts that a customer will be relocated to a non-empty route
     with a different capacity even if there is another empty route in between.
     """
     vehicle_types = [VehicleType(cap, 1) for cap in [12, 5, 1, 3]]
-    data = make_heterogeneous(read("data/OkSmall.txt"), vehicle_types)
+    data = make_heterogeneous(ok_small, vehicle_types)
+
     # Use a huge cost for load penalties to make other aspects irrelevant
     cost_evaluator = CostEvaluator(100_000, 6)
     rng = RandomNumberGenerator(seed=42)
@@ -323,7 +310,7 @@ def test_relocate_to_heterogeneous_empty_route():
 
 
 @mark.parametrize(
-    ("operator", "base_cost", "fixed_cost"),
+    ("op", "base_cost", "fixed_cost"),
     [
         (Exchange10, 2_346, 0),
         (Exchange10, 2_346, 100),
@@ -333,7 +320,7 @@ def test_relocate_to_heterogeneous_empty_route():
         (Exchange30, 135, 997),
     ],
 )
-def test_relocate_fixed_vehicle_cost(operator, base_cost, fixed_cost):
+def test_relocate_fixed_vehicle_cost(ok_small, op, base_cost, fixed_cost):
     """
     Tests that relocate operators - (N, M)-exchange where M == 0 - also take
     into account fixed vehicle costs changes if one of the routes is empty. In
@@ -341,10 +328,8 @@ def test_relocate_fixed_vehicle_cost(operator, base_cost, fixed_cost):
     not changed), and vary the fixed vehicle cost. The total delta cost should
     also vary as a result.
     """
-    data = read("data/OkSmall.txt")
-    data = make_heterogeneous(data, [VehicleType(10, 2, fixed_cost)])
-
-    op = operator(data)
+    data = make_heterogeneous(ok_small, [VehicleType(10, 2, fixed_cost)])
+    op = op(data)
 
     route1 = Route(data, idx=0, vehicle_type=0)
     for loc in [2, 4, 1, 3]:
diff --git a/pyvrp/search/tests/test_LocalSearch.py b/pyvrp/search/tests/test_LocalSearch.py
index 2e1bdd8..45eb224 100644
--- a/pyvrp/search/tests/test_LocalSearch.py
+++ b/pyvrp/search/tests/test_LocalSearch.py
@@ -18,67 +18,65 @@ from pyvrp.search import (
     compute_neighbours,
 )
 from pyvrp.search._search import LocalSearch as cpp_LocalSearch
-from pyvrp.tests.helpers import make_heterogeneous, read
+from pyvrp.tests.helpers import make_heterogeneous
 
 
-def test_local_search_returns_same_solution_with_empty_neighbourhood():
+def test_local_search_returns_same_solution_with_empty_neighbourhood(ok_small):
     """
     Tests that calling the local search when it only has node operators and
     an empty neighbourhood is a no-op: since the node operators respect the
     neighbourhood definition, they cannot do anything with an empty
     neighbourhood.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
-    ls = LocalSearch(data, rng, [[] for _ in range(data.num_clients + 1)])
-    ls.add_node_operator(Exchange10(data))
-    ls.add_node_operator(Exchange11(data))
+    neighbours = [[] for _ in range(ok_small.num_clients + 1)]
+    ls = LocalSearch(ok_small, rng, neighbours)
+    ls.add_node_operator(Exchange10(ok_small))
+    ls.add_node_operator(Exchange11(ok_small))
 
     # The search is completed after one iteration due to the empty
     # neighbourhood. This also prevents moves involving empty routes,
     # which are not explicitly forbidden by the empty neighbourhood.
-    sol = Solution.make_random(data, rng)
+    sol = Solution.make_random(ok_small, rng)
     assert_equal(ls.search(sol, cost_evaluator), sol)
 
 
 @mark.parametrize("size", [1, 2, 3, 4, 6, 7])  # num_clients + 1 == 5
-def test_local_search_raises_when_neighbourhood_dimensions_do_not_match(size):
+def test_raises_when_neighbourhood_dimensions_do_not_match(ok_small, size):
     """
     Tests that the local search raises when the neighbourhood size does not
     correspond to the problem dimensions.
     """
-    data = read("data/OkSmall.txt")
     rng = RandomNumberGenerator(seed=42)
 
     # Each of the given sizes is either smaller than or bigger than desired.
     neighbours = [[] for _ in range(size)]
 
     with assert_raises(RuntimeError):
-        LocalSearch(data, rng, neighbours)
+        LocalSearch(ok_small, rng, neighbours)
 
-    ls = LocalSearch(data, rng, compute_neighbours(data))
+    ls = LocalSearch(ok_small, rng, compute_neighbours(ok_small))
 
     with assert_raises(RuntimeError):
         ls.set_neighbours(neighbours)
 
 
-def test_local_search_raises_when_neighbourhood_contains_self_or_depot():
+def test_raises_when_neighbourhood_contains_self_or_depot(ok_small):
     """
     Tests that the local search raises when the granular neighbourhood contains
     the depot (for any client) or the client is in its own neighbourhood.
     """
-    data = read("data/OkSmall.txt")
     rng = RandomNumberGenerator(seed=42)
 
     neighbours = [[], [2], [3], [4], [0]]  # 4 has depot as neighbour
     with assert_raises(RuntimeError):
-        LocalSearch(data, rng, neighbours)
+        LocalSearch(ok_small, rng, neighbours)
 
     neighbours = [[], [1], [3], [4], [1]]  # 1 has itself as neighbour
     with assert_raises(RuntimeError):
-        LocalSearch(data, rng, neighbours)
+        LocalSearch(ok_small, rng, neighbours)
 
 
 @mark.parametrize(
@@ -98,6 +96,7 @@ def test_local_search_raises_when_neighbourhood_contains_self_or_depot():
     ],
 )
 def test_local_search_set_get_neighbours(
+    rc208,
     weight_wait_time: int,
     weight_time_warp: int,
     nb_granular: int,
@@ -107,12 +106,11 @@ def test_local_search_set_get_neighbours(
     """
     Tests setting and getting neighbours on the local search instance.
     """
-    data = read("data/RC208.txt", "solomon", round_func="trunc")
     rng = RandomNumberGenerator(seed=42)
 
     params = NeighbourhoodParams(nb_granular=1)
-    prev_neighbours = compute_neighbours(data, params)
-    ls = LocalSearch(data, rng, prev_neighbours)
+    prev_neighbours = compute_neighbours(rc208, params)
+    ls = LocalSearch(rc208, rng, prev_neighbours)
 
     params = NeighbourhoodParams(
         weight_wait_time,
@@ -121,7 +119,7 @@ def test_local_search_set_get_neighbours(
         symmetric_proximity,
         symmetric_neighbours,
     )
-    neighbours = compute_neighbours(data, params)
+    neighbours = compute_neighbours(rc208, params)
 
     # Test that before we set neighbours we don't have same
     assert_(ls.get_neighbours() != neighbours)
@@ -140,17 +138,15 @@ def test_local_search_set_get_neighbours(
     assert_(ls.get_neighbours() != neighbours)
 
 
-def test_reoptimize_changed_objective_timewarp_OkSmall():
+def test_reoptimize_changed_objective_timewarp_OkSmall(ok_small):
     """
     This test reproduces a bug where loadSolution in LocalSearch.cpp would
     reset the timewarp for a route to 0 if the route was not changed. This
     would cause improving moves with a smaller timewarp not to be considered
     because the current cost doesn't count the current time warp.
     """
-    data = read("data/OkSmall.txt")
     rng = RandomNumberGenerator(seed=42)
-
-    sol = Solution(data, [[1, 2, 3, 4]])
+    sol = Solution(ok_small, [[1, 2, 3, 4]])
 
     # We make neighbours only contain 1 -> 2, so the only feasible move
     # is changing [1, 2, 3, 4] into [2, 1, 3, 4] or moving one of the nodes
@@ -158,8 +154,8 @@ def test_reoptimize_changed_objective_timewarp_OkSmall():
     # smaller time warp, they are considered improving moves with a
     # sufficiently large time warp penalty.
     neighbours = [[], [2], [], [], []]  # 1 -> 2 only
-    ls = LocalSearch(data, rng, neighbours)
-    ls.add_node_operator(Exchange10(data))
+    ls = LocalSearch(ok_small, rng, neighbours)
+    ls.add_node_operator(Exchange10(ok_small))
 
     # With 0 timewarp penalty, the solution should not change since
     # the solution [2, 1, 3, 4] has larger distance
@@ -176,23 +172,23 @@ def test_reoptimize_changed_objective_timewarp_OkSmall():
     assert_(improved_cost < cost_evaluator_tw.penalised_cost(sol))
 
 
-def test_prize_collecting():
+def test_prize_collecting(prize_collecting):
     """
     Tests that local search works on a small prize-collecting instance.
     """
-    data = read("data/p06-2-50.vrp", round_func="dimacs")
     rng = RandomNumberGenerator(seed=42)
     cost_evaluator = CostEvaluator(1, 1)
 
-    sol = Solution.make_random(data, rng)
+    sol = Solution.make_random(prize_collecting, rng)
     sol_cost = cost_evaluator.penalised_cost(sol)
 
     # Random solutions are complete...
-    assert_equal(sol.num_clients(), data.num_clients)
+    assert_equal(sol.num_clients(), prize_collecting.num_clients)
 
-    ls = LocalSearch(data, rng, compute_neighbours(data))
-    ls.add_node_operator(Exchange10(data))  # relocate
-    ls.add_node_operator(Exchange11(data))  # swap
+    neighbours = compute_neighbours(prize_collecting)
+    ls = LocalSearch(prize_collecting, rng, neighbours)
+    ls.add_node_operator(Exchange10(prize_collecting))  # relocate
+    ls.add_node_operator(Exchange11(prize_collecting))  # swap
 
     improved = ls.search(sol, cost_evaluator)
     improved_cost = cost_evaluator.penalised_cost(improved)
@@ -202,20 +198,19 @@ def test_prize_collecting():
     assert_(improved_cost < sol_cost)
 
 
-def test_cpp_shuffle_results_in_different_solution():
+def test_cpp_shuffle_results_in_different_solution(rc208):
     """
     Tests that calling shuffle changes the evaluation order, which can well
     result in different solutions generated from the same initial solution.
     """
-    data = read("data/RC208.txt", "solomon", round_func="trunc")
     rng = RandomNumberGenerator(seed=42)
 
-    ls = cpp_LocalSearch(data, compute_neighbours(data))
-    ls.add_node_operator(Exchange10(data))
-    ls.add_node_operator(Exchange11(data))
+    ls = cpp_LocalSearch(rc208, compute_neighbours(rc208))
+    ls.add_node_operator(Exchange10(rc208))
+    ls.add_node_operator(Exchange11(rc208))
 
     cost_evaluator = CostEvaluator(1, 1)
-    sol = Solution.make_random(data, rng)
+    sol = Solution.make_random(rc208, rng)
 
     # LocalSearch::search is deterministic, so two calls with the same base
     # solution should result in the same improved solution.
@@ -230,29 +225,28 @@ def test_cpp_shuffle_results_in_different_solution():
     assert_(improved3 != improved1)
 
 
-def test_route_vehicle_types_are_preserved_for_locally_optimal_solutions():
+def test_vehicle_types_are_preserved_for_locally_optimal_solutions(rc208):
     """
     Tests that a solution that is already locally optimal returns the same
     solution, particularly w.r.t. the underlying vehicles. This exercises an
     issue where loading the solution in the local search did not preserve the
     vehicle types.
     """
-    data = read("data/RC208.txt", "solomon", round_func="trunc")
     rng = RandomNumberGenerator(seed=42)
-    neighbours = compute_neighbours(data)
+    neighbours = compute_neighbours(rc208)
 
-    ls = cpp_LocalSearch(data, neighbours)
-    ls.add_node_operator(Exchange10(data))
-    ls.add_node_operator(Exchange11(data))
+    ls = cpp_LocalSearch(rc208, neighbours)
+    ls.add_node_operator(Exchange10(rc208))
+    ls.add_node_operator(Exchange11(rc208))
 
     cost_evaluator = CostEvaluator(1, 1)
-    sol = Solution.make_random(data, rng)
+    sol = Solution.make_random(rc208, rng)
 
     improved = ls.search(sol, cost_evaluator)
 
     # Now make the instance heterogeneous and update the local search
     data = make_heterogeneous(
-        data, [VehicleType(1000, 25), VehicleType(1000, 25)]
+        rc208, [VehicleType(1000, 25), VehicleType(1000, 25)]
     )
     ls = cpp_LocalSearch(data, neighbours)
     ls.add_node_operator(Exchange10(data))
@@ -268,15 +262,16 @@ def test_route_vehicle_types_are_preserved_for_locally_optimal_solutions():
     assert_equal(further_improved, improved)
 
 
-def test_bugfix_vehicle_type_offsets():
+def test_bugfix_vehicle_type_offsets(ok_small):
     """
     See https://github.com/PyVRP/PyVRP/pull/292 for details. This exercises a
     fix to a bug that would crash local search due to an incorrect internal
     mapping of vehicle types to route indices if the next vehicle type had
     more vehicles than the previous.
     """
-    data = read("data/OkSmall.txt")
-    data = make_heterogeneous(data, [VehicleType(10, 1), VehicleType(10, 2)])
+    data = make_heterogeneous(
+        ok_small, [VehicleType(10, 1), VehicleType(10, 2)]
+    )
 
     ls = cpp_LocalSearch(data, compute_neighbours(data))
     ls.add_node_operator(Exchange10(data))
@@ -292,20 +287,19 @@ def test_bugfix_vehicle_type_offsets():
     assert_(improved_cost <= current_cost)
 
 
-def test_intensify_overlap_tolerance():
+def test_intensify_overlap_tolerance(rc208):
     """
     Tests that the local search's intensifying route operators respect the
     route overlap tolerance argument.
     """
-    data = read("data/RC208.txt", "solomon", round_func="trunc")
     rng = RandomNumberGenerator(seed=42)
 
-    neighbours = compute_neighbours(data)
-    ls = LocalSearch(data, rng, neighbours)
-    ls.add_route_operator(RelocateStar(data))
+    neighbours = compute_neighbours(rc208)
+    ls = LocalSearch(rc208, rng, neighbours)
+    ls.add_route_operator(RelocateStar(rc208))
 
     cost_eval = CostEvaluator(1, 1)
-    sol = Solution.make_random(data, rng)
+    sol = Solution.make_random(rc208, rng)
 
     # Overlap tolerance is zero, so no routes should have overlap and thus
     # no intensification should take place.
@@ -320,62 +314,59 @@ def test_intensify_overlap_tolerance():
 
 
 @mark.parametrize("tol", [-1.0, -0.01, 1.01, 10.9, 1000])
-def test_intensify_overlap_tolerance_raises_outside_unit_interval(tol):
+def test_intensify_overlap_tolerance_raises_outside_unit_interval(rc208, tol):
     """
     Tests that calling ``intensify()`` raises when the overlap tolerance
     argument is not in [0, 1].
     """
-    data = read("data/RC208.txt", "solomon", round_func="trunc")
     rng = RandomNumberGenerator(seed=42)
 
-    neighbours = compute_neighbours(data)
-    ls = LocalSearch(data, rng, neighbours)
-    ls.add_route_operator(RelocateStar(data))
+    neighbours = compute_neighbours(rc208)
+    ls = LocalSearch(rc208, rng, neighbours)
+    ls.add_route_operator(RelocateStar(rc208))
 
     cost_eval = CostEvaluator(1, 1)
-    sol = Solution.make_random(data, rng)
+    sol = Solution.make_random(rc208, rng)
 
     with assert_raises(RuntimeError):  # each tolerance value is outside [0, 1]
         ls.intensify(sol, cost_eval, overlap_tolerance=tol)
 
 
-def test_no_op_results_in_same_solution():
+def test_no_op_results_in_same_solution(ok_small):
     """
     Tests that calling local search without first adding node or route
     operators is a no-op, and returns the same solution as the one that was
     given to it.
     """
-    data = read("data/OkSmall.txt")
     rng = RandomNumberGenerator(seed=42)
 
     cost_eval = CostEvaluator(1, 1)
-    sol = Solution.make_random(data, rng)
+    sol = Solution.make_random(ok_small, rng)
 
     # Empty local search does not actually search anything, so it should return
     # the exact same solution as what was passed in.
-    ls = LocalSearch(data, rng, compute_neighbours(data))
+    ls = LocalSearch(ok_small, rng, compute_neighbours(ok_small))
     assert_equal(ls(sol, cost_eval), sol)
     assert_equal(ls.search(sol, cost_eval), sol)
     assert_equal(ls.intensify(sol, cost_eval), sol)
 
 
-def test_intensify_can_improve_solution_further():
+def test_intensify_can_improve_solution_further(rc208):
     """
     Tests that ``intensify()`` improves a solution further once ``search()`` is
     stuck.
     """
-    data = read("data/RC208.txt", "solomon", round_func="trunc")
     rng = RandomNumberGenerator(seed=11)
 
-    ls = LocalSearch(data, rng, compute_neighbours(data))
-    ls.add_node_operator(Exchange11(data))
-    ls.add_route_operator(SwapStar(data))
+    ls = LocalSearch(rc208, rng, compute_neighbours(rc208))
+    ls.add_node_operator(Exchange11(rc208))
+    ls.add_route_operator(SwapStar(rc208))
 
     cost_eval = CostEvaluator(1, 1)
 
     # The following solution is locally optimal w.r.t. the node operators. This
     # solution cannot be improved further by repeated calls to ``search()``.
-    search_opt = ls.search(Solution.make_random(data, rng), cost_eval)
+    search_opt = ls.search(Solution.make_random(rc208, rng), cost_eval)
     search_cost = cost_eval.penalised_cost(search_opt)
 
     # But it can be improved further using the intensifying route operators,
@@ -395,21 +386,20 @@ def test_intensify_can_improve_solution_further():
         assert_equal(ls.intensify(intensify_opt, cost_eval), intensify_opt)
 
 
-def test_local_search_completes_incomplete_solutions():
+def test_local_search_completes_incomplete_solutions(ok_small_prizes):
     """
     Tests that the local search object improve solutions that are incomplete,
     and returns a completed solution. Passing an incomplete solution should
     return a completed solution after search.
     """
-    data = read("data/OkSmallPrizes.txt")
     rng = RandomNumberGenerator(seed=42)
 
-    ls = LocalSearch(data, rng, compute_neighbours(data))
-    ls.add_node_operator(Exchange10(data))
+    ls = LocalSearch(ok_small_prizes, rng, compute_neighbours(ok_small_prizes))
+    ls.add_node_operator(Exchange10(ok_small_prizes))
 
     cost_eval = CostEvaluator(1, 1)
-    sol = Solution(data, [[2], [3, 4]])  # 1 is required but not visited
-    assert_(not sol.is_complete())
+    sol = Solution(ok_small_prizes, [[2], [3, 4]])
+    assert_(not sol.is_complete())  # 1 is required but not visited
 
     new_sol = ls.search(sol, cost_eval)
     assert_(new_sol.is_complete())
diff --git a/pyvrp/search/tests/test_MoveTwoClientsReversed.py b/pyvrp/search/tests/test_MoveTwoClientsReversed.py
index 0fe9162..a9a3be7 100644
--- a/pyvrp/search/tests/test_MoveTwoClientsReversed.py
+++ b/pyvrp/search/tests/test_MoveTwoClientsReversed.py
@@ -18,22 +18,20 @@ from pyvrp.search import (
 )
 from pyvrp.search._search import LocalSearch as cpp_LocalSearch
 from pyvrp.search._search import Node, Route
-from pyvrp.tests.helpers import read
 
 
-def test_single_route_OkSmall():
+def test_single_route_OkSmall(ok_small):
     """
     This test checks that MoveTwoClientsReversed properly solves the small
     instance where we know what is going on.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
 
     # Only neighbours are 1 -> 4 and 2 -> 1.
-    ls = cpp_LocalSearch(data, [[], [4], [1], [], []])
-    ls.add_node_operator(MoveTwoClientsReversed(data))
+    ls = cpp_LocalSearch(ok_small, [[], [4], [1], [], []])
+    ls.add_node_operator(MoveTwoClientsReversed(ok_small))
 
-    sol = Solution(data, [[1, 4, 2, 3]])
+    sol = Solution(ok_small, [[1, 4, 2, 3]])
     improved_sol = ls.search(sol, cost_evaluator)
 
     # The new solution should strictly improve on our original solution.
@@ -44,34 +42,33 @@ def test_single_route_OkSmall():
 
     # (2, 3) was inserted after 1 as 1 -> 3 -> 2 -> 4. Then (1, 3) got inserted
     # after 4 as 2 -> 4 -> 3 -> 1.
-    expected = Solution(data, [[2, 4, 3, 1]])
+    expected = Solution(ok_small, [[2, 4, 3, 1]])
     assert_equal(improved_sol, expected)
 
     # These two-route solutions can all be created by MoveTwoClientsReversed
     # from the returned solution. So they must have a cost that's at best equal
     # to the returned solution's cost.
     for routes in ([[3, 1], [4, 2]], [[2, 1], [3, 4]], [[2, 4], [1, 3]]):
-        other = Solution(data, routes)
+        other = Solution(ok_small, routes)
         improved_cost = cost_evaluator.penalised_cost(improved_sol)
         other_cost = cost_evaluator.penalised_cost(other)
         assert_(improved_cost <= other_cost)
 
 
 @mark.parametrize("seed", [2643, 2742, 2941, 3457, 4299, 4497, 6178, 6434])
-def test_RC208_instance(seed: int):
+def test_RC208_instance(rc208, seed: int):
     """
     Test a larger instance over several seeds.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=seed)
 
-    nb_params = NeighbourhoodParams(nb_granular=data.num_clients)
-    ls = LocalSearch(data, rng, compute_neighbours(data, nb_params))
-    ls.add_node_operator(MoveTwoClientsReversed(data))
+    nb_params = NeighbourhoodParams(nb_granular=rc208.num_clients)
+    ls = LocalSearch(rc208, rng, compute_neighbours(rc208, nb_params))
+    ls.add_node_operator(MoveTwoClientsReversed(rc208))
 
-    single_route = list(range(1, data.num_clients + 1))
-    sol = Solution(data, [single_route])
+    single_route = list(range(1, rc208.num_clients + 1))
+    sol = Solution(rc208, [single_route])
     improved_sol = ls.search(sol, cost_evaluator)
 
     # The new solution should strictly improve on our original solution.
diff --git a/pyvrp/search/tests/test_RelocateStar.py b/pyvrp/search/tests/test_RelocateStar.py
index 02ef07f..1bed1ca 100644
--- a/pyvrp/search/tests/test_RelocateStar.py
+++ b/pyvrp/search/tests/test_RelocateStar.py
@@ -9,27 +9,25 @@ from pyvrp.search import (
     RelocateStar,
     compute_neighbours,
 )
-from pyvrp.tests.helpers import read
 
 
-def test_exchange10_and_relocate_star_are_same_large_neighbourhoods():
+def test_exchange10_and_relocate_star_are_same_large_neighbourhoods(rc208):
     """
     With sufficiently large granular neighbourhoods, (1, 0)-Exchange and
     RELOCATE* find the exact same solutions. Only when the granular
     neighbourhood is restricted do these solutions start to differ.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
-    nb_params = NeighbourhoodParams(nb_granular=data.num_clients)
-    ls = LocalSearch(data, rng, compute_neighbours(data, nb_params))
+    nb_params = NeighbourhoodParams(nb_granular=rc208.num_clients)
+    ls = LocalSearch(rc208, rng, compute_neighbours(rc208, nb_params))
 
-    ls.add_node_operator(Exchange10(data))
-    ls.add_route_operator(RelocateStar(data))
+    ls.add_node_operator(Exchange10(rc208))
+    ls.add_route_operator(RelocateStar(rc208))
 
     for _ in range(10):  # repeat a few times to really make sure
-        sol = Solution.make_random(data, rng)
+        sol = Solution.make_random(rc208, rng)
         exchange_sol = ls.search(sol, cost_evaluator)
         relocate_sol = ls.intensify(
             exchange_sol, cost_evaluator, overlap_tolerance=1
@@ -44,22 +42,23 @@ def test_exchange10_and_relocate_star_are_same_large_neighbourhoods():
 
 
 @mark.parametrize("size", [2, 5, 10])
-def test_exchange10_and_relocate_star_differ_small_neighbourhoods(size: int):
+def test_exchange10_and_relocate_star_differ_small_neighbourhoods(
+    rc208, size: int
+):
     """
     This test restricts the sizes of the granular neighbourhoods, so now
     (1, 0)-Exchange and RELOCATE* should start to differ.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
     nb_params = NeighbourhoodParams(nb_granular=size)
-    ls = LocalSearch(data, rng, compute_neighbours(data, nb_params))
+    ls = LocalSearch(rc208, rng, compute_neighbours(rc208, nb_params))
 
-    ls.add_node_operator(Exchange10(data))
-    ls.add_route_operator(RelocateStar(data))
+    ls.add_node_operator(Exchange10(rc208))
+    ls.add_route_operator(RelocateStar(rc208))
 
-    sol = Solution.make_random(data, rng)
+    sol = Solution.make_random(rc208, rng)
     exchange_sol = ls.search(sol, cost_evaluator)
     relocate_sol = ls.intensify(
         exchange_sol, cost_evaluator, overlap_tolerance=1
diff --git a/pyvrp/search/tests/test_Route.py b/pyvrp/search/tests/test_Route.py
index cb0e1dc..41d5923 100644
--- a/pyvrp/search/tests/test_Route.py
+++ b/pyvrp/search/tests/test_Route.py
@@ -6,7 +6,7 @@ from numpy.testing import assert_, assert_allclose, assert_equal
 
 from pyvrp import Client, ProblemData, VehicleType
 from pyvrp.search._search import Node, Route
-from pyvrp.tests.helpers import make_heterogeneous, read
+from pyvrp.tests.helpers import make_heterogeneous
 
 
 @pytest.mark.parametrize("loc", [0, 1, 10])
@@ -21,13 +21,12 @@ def test_node_init(loc: int):
 
 
 @pytest.mark.parametrize(("idx", "vehicle_type"), [(0, 0), (1, 0), (1, 1)])
-def test_route_init(idx: int, vehicle_type: int):
+def test_route_init(ok_small, idx: int, vehicle_type: int):
     """
     Tests that after initialisation, a Route has the given index and vehicle
     type.
     """
-    data = read("data/OkSmall.txt")
-    data = make_heterogeneous(data, [VehicleType(1, 1), VehicleType(2, 2)])
+    data = make_heterogeneous(ok_small, [VehicleType(1, 1), VehicleType(2, 2)])
 
     route = Route(data, idx=idx, vehicle_type=vehicle_type)
     assert_equal(route.idx, idx)
@@ -44,15 +43,14 @@ def test_new_nodes_are_not_depots(loc: int):
     assert_(not node.is_depot())
 
 
-def test_route_insert_and_remove_updates_node_idx_and_route_properties():
+def test_insert_and_remove_update_node_idx_and_route_properties(ok_small):
     """
     Tests that after a node is inserted into a route, its index and route
     properties are updated to reflect its new position in the route. After
     the node is removed from the route, these properties revert to their
     defaults.
     """
-    data = read("data/OkSmall.txt")
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
 
     # After construction, the node is not in a route yet.
     node = Node(loc=1)
@@ -70,13 +68,12 @@ def test_route_insert_and_remove_updates_node_idx_and_route_properties():
     assert_(node.route is None)
 
 
-def test_route_depots_are_depots():
+def test_route_depots_are_depots(ok_small):
     """
     Tests that the start and end depot nodes in a route known they are, in
     fact, depots.
     """
-    data = read("data/OkSmall.txt")
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
 
     for loc in range(2):
         # The depots flank the clients at indices {1, ..., len(route)}. Thus,
@@ -86,12 +83,11 @@ def test_route_depots_are_depots():
         assert_(route[len(route) + 1].is_depot())
 
 
-def test_route_append_increases_route_len():
+def test_route_append_increases_route_len(ok_small):
     """
     Tests that appending nodes to a route increases the route's length.
     """
-    data = read("data/OkSmall.txt")
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
     assert_equal(len(route), 0)
 
     node = Node(loc=1)
@@ -105,13 +101,12 @@ def test_route_append_increases_route_len():
     assert_(route[2] is node)  # pointers, so must be same object
 
 
-def test_route_insert():
+def test_route_insert(ok_small):
     """
     Tests that inserting and appending nodes works as expected: appending adds
     to the end, inserting places at the given index.
     """
-    data = read("data/OkSmall.txt")
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
     assert_equal(len(route), 0)
 
     # Insert a few nodes so we have an actual route.
@@ -129,13 +124,12 @@ def test_route_insert():
     assert_equal(route[3].client, 2)
 
 
-def test_route_iter_returns_all_clients():
+def test_route_iter_returns_all_clients(ok_small):
     """
     Tests that iterating over a route returns all clients in the route, but
     not the depots.
     """
-    data = read("data/OkSmall.txt")
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
 
     for loc in [1, 2, 3]:
         route.append(Node(loc=loc))
@@ -150,12 +144,11 @@ def test_route_iter_returns_all_clients():
     assert_equal(nodes[2], route[3])
 
 
-def test_route_add_and_delete_client_leaves_route_empty():
+def test_route_add_and_delete_client_leaves_route_empty(ok_small):
     """
     Tests that adding and removing a client leaves a route empty.
     """
-    data = read("data/OkSmall.txt")
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
 
     route.append(Node(loc=1))
     assert_equal(len(route), 1)
@@ -164,12 +157,11 @@ def test_route_add_and_delete_client_leaves_route_empty():
     assert_equal(len(route), 0)
 
 
-def test_route_delete_reduces_size_by_one():
+def test_route_delete_reduces_size_by_one(ok_small):
     """
     Deleting an item at an index removes only the indicated index, not more.
     """
-    data = read("data/OkSmall.txt")
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
 
     route.append(Node(loc=1))
     route.append(Node(loc=2))
@@ -181,13 +173,12 @@ def test_route_delete_reduces_size_by_one():
 
 
 @pytest.mark.parametrize("num_nodes", range(4))
-def test_route_clear_empties_entire_route(num_nodes: int):
+def test_route_clear_empties_entire_route(ok_small, num_nodes: int):
     """
     The clear() method should clear the entire route, not just remove part of
     it.
     """
-    data = read("data/OkSmall.txt")
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
 
     for loc in range(num_nodes):
         route.append(Node(loc=loc))
@@ -197,16 +188,13 @@ def test_route_clear_empties_entire_route(num_nodes: int):
     assert_equal(len(route), 0)
 
 
-def test_excess_load():
+def test_excess_load(ok_small):
     """
     Tests that the route calculations excess load correctly.
     """
-    data = read("data/OkSmall.txt")
-    route = Route(data, idx=0, vehicle_type=0)
-
+    route = Route(ok_small, idx=0, vehicle_type=0)
     for loc in [1, 2, 3, 4]:
         route.append(Node(loc=loc))
-
     route.update()
 
     # The instance has four clients, which have a total demand of 18. The only
@@ -218,40 +206,38 @@ def test_excess_load():
 
 
 @pytest.mark.parametrize("client", [1, 2, 3, 4])
-def test_dist_and_load_for_single_client_routes(client: int):
+def test_dist_and_load_for_single_client_routes(ok_small, client: int):
     """
     Tests that the route calculates distance and load correctly for a
     single-client route.
     """
-    data = read("data/OkSmall.txt")
-
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
     route.append(Node(loc=client))
     route.update()
 
     # Only the client has any demand, so the total route load should be equal
     # to it.
-    assert_equal(route.load(), data.client(client).demand)
-    assert_equal(route.load_between(0, 2), data.client(client).demand)
+    assert_equal(route.load(), ok_small.client(client).demand)
+    assert_equal(route.load_between(0, 2), ok_small.client(client).demand)
 
     # The load_between() function is inclusive.
     assert_equal(route.load_between(0, 0), 0)
-    assert_equal(route.load_between(1, 1), data.client(client).demand)
+    assert_equal(route.load_between(1, 1), ok_small.client(client).demand)
 
     # Distances on various segments of the route.
-    assert_equal(route.dist_between(0, 1), data.dist(0, client))
-    assert_equal(route.dist_between(1, 2), data.dist(client, 0))
+    assert_equal(route.dist_between(0, 1), ok_small.dist(0, client))
+    assert_equal(route.dist_between(1, 2), ok_small.dist(client, 0))
     assert_equal(
-        route.dist_between(0, 2), data.dist(0, client) + data.dist(client, 0)
+        route.dist_between(0, 2),
+        ok_small.dist(0, client) + ok_small.dist(client, 0),
     )
 
 
-def test_route_overlaps_with_self_no_matter_the_tolerance_value():
+def test_route_overlaps_with_self_no_matter_the_tolerance_value(ok_small):
     """
     Tests that a route always overlaps with itself.
     """
-    data = read("data/OkSmall.txt")
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
     route.append(Node(loc=1))
     route.append(Node(loc=2))
 
@@ -262,19 +248,17 @@ def test_route_overlaps_with_self_no_matter_the_tolerance_value():
     assert_(route.overlaps_with(route, 1))
 
 
-def test_all_routes_overlap_with_maximum_tolerance_value():
+def test_all_routes_overlap_with_maximum_tolerance_value(ok_small):
     """
     Tests that any route overlaps with any other route with the maximum
     tolerance value.
     """
-    data = read("data/OkSmall.txt")
-
-    route1 = Route(data, idx=0, vehicle_type=0)
+    route1 = Route(ok_small, idx=0, vehicle_type=0)
     for loc in [1, 2]:
         route1.append(Node(loc=loc))
     route1.update()
 
-    route2 = Route(data, idx=0, vehicle_type=0)
+    route2 = Route(ok_small, idx=0, vehicle_type=0)
     for loc in [3, 4]:
         route2.append(Node(loc=loc))
     route2.update()
@@ -289,44 +273,50 @@ def test_all_routes_overlap_with_maximum_tolerance_value():
     assert_(route2.overlaps_with(route1, 1))
 
 
-def test_data_is_not_updated_until_update_call():
+def test_data_is_not_updated_until_update_call(ok_small):
     """
     Tests that route statistics like distance, time window segments, and load
     are not updated until ``update()`` has been called.
     """
-    data = read("data/OkSmall.txt")
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
 
     # Add a new client to the route. update() has not been called, so the route
     # statistics are not correct.
     route.append(Node(loc=1))
-    assert_(route.load() != data.client(1).demand)
-    assert_(route.dist_between(0, 2) != data.dist(0, 1) + data.dist(1, 0))
+    assert_(route.load() != ok_small.client(1).demand)
+    assert_(
+        route.dist_between(0, 2) != ok_small.dist(0, 1) + ok_small.dist(1, 0)
+    )
 
     # Update. This recalculates the statistics, which should now be correct.
     route.update()
-    assert_equal(route.load(), data.client(1).demand)
-    assert_equal(route.dist_between(0, 2), data.dist(0, 1) + data.dist(1, 0))
+    assert_equal(route.load(), ok_small.client(1).demand)
+    assert_equal(
+        route.dist_between(0, 2), ok_small.dist(0, 1) + ok_small.dist(1, 0)
+    )
 
     # Same story with another client: incorrect before update, correct after.
     route.append(Node(loc=2))
-    assert_(route.load() != data.client(1).demand + data.client(2).demand)
+    assert_(
+        route.load() != ok_small.client(1).demand + ok_small.client(2).demand
+    )
 
     route.update()
-    assert_equal(route.load(), data.client(1).demand + data.client(2).demand)
+    assert_equal(
+        route.load(), ok_small.client(1).demand + ok_small.client(2).demand
+    )
     assert_equal(
         route.dist_between(0, 3),
-        data.dist(0, 1) + data.dist(1, 2) + data.dist(2, 0),
+        ok_small.dist(0, 1) + ok_small.dist(1, 2) + ok_small.dist(2, 0),
     )
 
 
 @pytest.mark.parametrize("locs", [(1, 2, 3), (3, 4), (1,)])
-def test_str_contains_route(locs: List[int]):
+def test_str_contains_route(ok_small, locs: List[int]):
     """
     Test that each client in the route is also printed in the route's __str__.
     """
-    data = read("data/OkSmall.txt")
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
 
     for loc in locs:
         route.append(Node(loc=loc))
@@ -335,24 +325,22 @@ def test_str_contains_route(locs: List[int]):
         assert_(str(loc) in str(route))
 
 
-def test_route_tws_access():
+def test_route_tws_access(ok_small):
     """
     Tests access to a client's or depot's time window segment, as tracked by
     the route.
     """
-    data = read("data/OkSmall.txt")
-
-    route = Route(data, idx=0, vehicle_type=0)
-    for client in range(1, data.num_clients + 1):
+    route = Route(ok_small, idx=0, vehicle_type=0)
+    for client in range(1, ok_small.num_clients + 1):
         route.append(Node(loc=client))
 
     route.update()
 
     for loc in [0, 1, 2, 3, 4, 5]:  # = [depot, *clients, depot]
         if 0 < loc < len(route) + 1:
-            client = data.client(loc)  # if actual client
+            client = ok_small.client(loc)  # if actual client
         else:
-            client = data.client(0)  # else depot
+            client = ok_small.client(0)  # else depot
 
         tws = route.tws(loc)
         assert_equal(tws.tw_early(), client.tw_early)
@@ -362,15 +350,14 @@ def test_route_tws_access():
 
 
 @pytest.mark.parametrize("loc", [1, 2, 3, 4])
-def test_tws_between_same_client_returns_node_tws(loc: int):
+def test_tws_between_same_client_returns_node_tws(ok_small, loc: int):
     """
     Tests that calling the ``tws_between()`` with the same start and end
     arguments returns a node's time window segment data.
     """
-    data = read("data/OkSmall.txt")
-    client = data.client(loc)
+    client = ok_small.client(loc)
 
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
     route.append(Node(loc=loc))
     route.update()
 
@@ -384,15 +371,13 @@ def test_tws_between_same_client_returns_node_tws(loc: int):
     assert_equal(route.time_warp(), 0)
 
 
-def test_tws_between_same_as_tws_before_after_when_one_side_is_depot():
+def test_tws_between_same_as_tws_before_after_when_one_side_is_depot(ok_small):
     """
     Tests that ``tws_between()`` returns the same value as ``tws_before()`` or
     ``tws_after()`` when one side is the depot.
     """
-    data = read("data/OkSmall.txt")
-
-    route = Route(data, idx=0, vehicle_type=0)
-    for client in range(1, data.num_clients + 1):
+    route = Route(ok_small, idx=0, vehicle_type=0)
+    for client in range(1, ok_small.num_clients + 1):
         route.append(Node(loc=client))
 
     route.update()
@@ -411,18 +396,16 @@ def test_tws_between_same_as_tws_before_after_when_one_side_is_depot():
         assert_equal(after.total_time_warp(), between_after.total_time_warp())
 
 
-def test_tws_between_single_route_solution_has_time_warp_in_the_right_places():
+def test_tws_between_single_route_solution_has_correct_time_warp(ok_small):
     """
     Tests time window segment access on a single-route solution where we know
     exactly where in the route time warp occurs.
     """
-    data = read("data/OkSmall.txt")
-
-    route = Route(data, idx=0, vehicle_type=0)
-    for client in range(1, data.num_clients + 1):
+    route = Route(ok_small, idx=0, vehicle_type=0)
+    for client in range(1, ok_small.num_clients + 1):
         route.append(Node(loc=client))
 
-    assert_equal(len(route), data.num_clients)
+    assert_equal(len(route), ok_small.num_clients)
 
     route.update()
     assert_(route.has_time_warp())
@@ -441,15 +424,13 @@ def test_tws_between_single_route_solution_has_time_warp_in_the_right_places():
         assert_equal(route.tws_between(start, end).total_time_warp(), 0)
 
 
-def test_distance_is_equal_to_dist_between_over_whole_route():
+def test_distance_is_equal_to_dist_between_over_whole_route(ok_small):
     """
     Tests that calling distance() on the route object is the same as calling
     dist_between() with the start and end depot indices as arguments.
     """
-    data = read("data/OkSmall.txt")
-
-    route = Route(data, idx=0, vehicle_type=0)
-    for client in range(1, data.num_clients + 1):
+    route = Route(ok_small, idx=0, vehicle_type=0)
+    for client in range(1, ok_small.num_clients + 1):
         route.append(Node(loc=client))
     route.update()
 
diff --git a/pyvrp/search/tests/test_SwapRoutes.py b/pyvrp/search/tests/test_SwapRoutes.py
index 6c15e5d..985430d 100644
--- a/pyvrp/search/tests/test_SwapRoutes.py
+++ b/pyvrp/search/tests/test_SwapRoutes.py
@@ -6,7 +6,7 @@ from numpy.testing import assert_, assert_allclose, assert_equal
 from pyvrp import CostEvaluator, VehicleType
 from pyvrp.search import SwapRoutes
 from pyvrp.search._search import Node, Route
-from pyvrp.tests.helpers import make_heterogeneous, read
+from pyvrp.tests.helpers import make_heterogeneous
 
 
 @pytest.mark.parametrize(
@@ -20,18 +20,16 @@ from pyvrp.tests.helpers import make_heterogeneous, read
         ([2, 3], [1, 4]),  # both non-empty equal length
     ],
 )
-def test_apply(visits1: List[int], visits2: List[int]):
+def test_apply(ok_small, visits1: List[int], visits2: List[int]):
     """
     Tests that applying SwapRoutes to two different routes indeed exchanges
     the visits.
     """
-    data = read("data/OkSmall.txt")
-
-    route1 = Route(data, idx=0, vehicle_type=0)
+    route1 = Route(ok_small, idx=0, vehicle_type=0)
     for loc in visits1:
         route1.append(Node(loc=loc))
 
-    route2 = Route(data, idx=1, vehicle_type=0)
+    route2 = Route(ok_small, idx=1, vehicle_type=0)
     for loc in visits2:
         route2.append(Node(loc=loc))
 
@@ -43,7 +41,7 @@ def test_apply(visits1: List[int], visits2: List[int]):
     assert_equal(visits1, [node.client for node in route1])
     assert_equal(visits2, [node.client for node in route2])
 
-    op = SwapRoutes(data)
+    op = SwapRoutes(ok_small)
     op.apply(route1, route2)
 
     # But after apply, the visits are now swapped.
@@ -51,15 +49,13 @@ def test_apply(visits1: List[int], visits2: List[int]):
     assert_equal(visits1, [node.client for node in route2])
 
 
-def test_evaluate_same_vehicle_type():
+def test_evaluate_same_vehicle_type(ok_small):
     """
     Tests that evaluate() returns 0 in case the same vehicle types are used,
     since in that case swapping cannot result in cost savings.
     """
-    data = read("data/OkSmall.txt")
-
-    route1 = Route(data, idx=0, vehicle_type=0)
-    route2 = Route(data, idx=1, vehicle_type=0)
+    route1 = Route(ok_small, idx=0, vehicle_type=0)
+    route2 = Route(ok_small, idx=1, vehicle_type=0)
     assert_equal(route1.vehicle_type, route2.vehicle_type)
 
     route1.append(Node(loc=1))
@@ -68,17 +64,18 @@ def test_evaluate_same_vehicle_type():
     route1.update()
     route2.update()
 
-    op = SwapRoutes(data)
+    op = SwapRoutes(ok_small)
     cost_eval = CostEvaluator(1, 1)
     assert_allclose(op.evaluate(route1, route2, cost_eval), 0)
 
 
-def test_evaluate_empty_routes():
+def test_evaluate_empty_routes(ok_small):
     """
     Tests that evaluate() returns 0 when one or both of the routes are empty.
     """
-    data = read("data/OkSmall.txt")
-    data = make_heterogeneous(data, [VehicleType(10, 3), VehicleType(10, 3)])
+    data = make_heterogeneous(
+        ok_small, [VehicleType(10, 3), VehicleType(10, 3)]
+    )
 
     route1 = Route(data, idx=0, vehicle_type=0)
     route2 = Route(data, idx=1, vehicle_type=1)
@@ -103,12 +100,13 @@ def test_evaluate_empty_routes():
     assert_allclose(op.evaluate(route3, route2, cost_eval), 0)
 
 
-def test_evaluate_capacity_differences():
+def test_evaluate_capacity_differences(ok_small):
     """
     Tests that changes in vehicle capacity violations are evaluated correctly.
     """
-    data = read("data/OkSmall.txt")
-    data = make_heterogeneous(data, [VehicleType(10, 1), VehicleType(20, 1)])
+    data = make_heterogeneous(
+        ok_small, [VehicleType(10, 1), VehicleType(20, 1)]
+    )
 
     route1 = Route(data, idx=0, vehicle_type=0)
     for loc in [1, 2, 4]:
@@ -141,14 +139,13 @@ def test_evaluate_capacity_differences():
     assert_allclose(op.evaluate(route2, route1, cost_eval), -200)
 
 
-def test_evaluate_shift_time_window_differences():
+def test_evaluate_shift_time_window_differences(ok_small):
     """
     Tests that SwapRoutes correctly evaluates changes in time warp due to
     different shift time windows.
     """
-    data = read("data/OkSmall.txt")
     data = make_heterogeneous(
-        data,
+        ok_small,
         [
             VehicleType(10, 1, tw_early=10_000, tw_late=15_000),
             VehicleType(10, 1, tw_early=15_000, tw_late=20_000),
diff --git a/pyvrp/search/tests/test_SwapStar.py b/pyvrp/search/tests/test_SwapStar.py
index d9c6b8a..8b355f6 100644
--- a/pyvrp/search/tests/test_SwapStar.py
+++ b/pyvrp/search/tests/test_SwapStar.py
@@ -18,30 +18,28 @@ from pyvrp.search import (
     compute_neighbours,
 )
 from pyvrp.search._search import Node, Route
-from pyvrp.tests.helpers import read
 
 
-def test_swap_star_identifies_additional_moves_over_regular_swap():
+def test_swap_star_identifies_additional_moves_over_regular_swap(rc208):
     """
     SWAP* can move two clients to any position in the routes, whereas regular
     swap ((1, 1)-exchange) must reinsert each client in the other's position.
     Thus, SWAP* should still be able to identify additional improving moves
     after (1, 1)-exchange gets stuck.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
     # For a fair comparison we should not hamper the node operator with
     # granularity restrictions.
-    nb_params = NeighbourhoodParams(nb_granular=data.num_clients)
-    ls = LocalSearch(data, rng, compute_neighbours(data, nb_params))
+    nb_params = NeighbourhoodParams(nb_granular=rc208.num_clients)
+    ls = LocalSearch(rc208, rng, compute_neighbours(rc208, nb_params))
 
-    ls.add_node_operator(Exchange11(data))
-    ls.add_route_operator(SwapStar(data))
+    ls.add_node_operator(Exchange11(rc208))
+    ls.add_route_operator(SwapStar(rc208))
 
     for _ in range(10):  # repeat a few times to really make sure
-        sol = Solution.make_random(data, rng)
+        sol = Solution.make_random(rc208, rng)
 
         swap_sol = ls.search(sol, cost_evaluator)
         swap_star_sol = ls.intensify(
@@ -59,22 +57,21 @@ def test_swap_star_identifies_additional_moves_over_regular_swap():
 
 
 @mark.parametrize("seed", [2643, 2742, 2941, 3457, 4299, 4497, 6178, 6434])
-def test_swap_star_on_RC208_instance(seed: int):
+def test_swap_star_on_RC208_instance(rc208, seed: int):
     """
     Evaluate SWAP* on the RC208 instance, over a few seeds.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=seed)
 
-    ls = LocalSearch(data, rng, compute_neighbours(data))
-    ls.add_route_operator(SwapStar(data))
+    ls = LocalSearch(rc208, rng, compute_neighbours(rc208))
+    ls.add_route_operator(SwapStar(rc208))
 
     # Make an initial solution that consists of two routes, by randomly
     # splitting the single-route solution.
-    route = list(range(1, data.num_clients + 1))
-    split = rng.randint(data.num_clients)
-    sol = Solution(data, [route[:split], route[split:]])
+    route = list(range(1, rc208.num_clients + 1))
+    split = rng.randint(rc208.num_clients)
+    sol = Solution(rc208, [route[:split], route[split:]])
     improved_sol = ls.intensify(sol, cost_evaluator, overlap_tolerance=1)
 
     # The new solution should strictly improve on our original solution, but
diff --git a/pyvrp/search/tests/test_TwoOpt.py b/pyvrp/search/tests/test_TwoOpt.py
index 1dd556f..2dcc826 100644
--- a/pyvrp/search/tests/test_TwoOpt.py
+++ b/pyvrp/search/tests/test_TwoOpt.py
@@ -18,22 +18,21 @@ from pyvrp.search import (
     compute_neighbours,
 )
 from pyvrp.search._search import Node, Route
-from pyvrp.tests.helpers import make_heterogeneous, read
+from pyvrp.tests.helpers import make_heterogeneous
 
 
-def test_OkSmall_instance():
+def test_OkSmall_instance(ok_small):
     """
     Test 2-OPT on the OkSmall instance where we know exactly what's going on.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
-    nb_params = NeighbourhoodParams(nb_granular=data.num_clients)
-    ls = LocalSearch(data, rng, compute_neighbours(data, nb_params))
-    ls.add_node_operator(TwoOpt(data))
+    nb_params = NeighbourhoodParams(nb_granular=ok_small.num_clients)
+    ls = LocalSearch(ok_small, rng, compute_neighbours(ok_small, nb_params))
+    ls.add_node_operator(TwoOpt(ok_small))
 
-    sol = Solution(data, [[1, 2, 3, 4]])
+    sol = Solution(ok_small, [[1, 2, 3, 4]])
     improved_sol = ls.search(sol, cost_evaluator)
 
     # The new solution should strictly improve on our original solution.
@@ -45,7 +44,7 @@ def test_OkSmall_instance():
     # First improving (U, V) node pair is (1, 3), which results in the route
     # [1, 3, 2, 4]. The second improving node pair involves the depot of an
     # empty route: (1, 0). This results in routes [1] and [3, 2, 4].
-    expected = Solution(data, [[1], [3, 2, 4]])
+    expected = Solution(ok_small, [[1], [3, 2, 4]])
     assert_equal(improved_sol, expected)
 
 
@@ -59,15 +58,16 @@ def test_OkSmall_instance():
         [VehicleType(8, 1), VehicleType(8, 1)],
     ],
 )
-def test_OkSmall_multiple_vehicle_types(vehicle_types: List[VehicleType]):
+def test_OkSmall_multiple_vehicle_types(
+    ok_small, vehicle_types: List[VehicleType]
+):
     """
     This test evaluates a 2-OPT move that is improving for some of the vehicle
     types, and not for others. Because the granular neighbourhood is very
     constrained, only a single 2-OPT can be applied. When it's better to do so,
     it should have been applied, and when it's not better, it should not.
     """
-    data = read("data/OkSmall.txt")
-    data = make_heterogeneous(data, vehicle_types)
+    data = make_heterogeneous(ok_small, vehicle_types)
 
     cost_evaluator = CostEvaluator(10_000, 6)  # large capacity penalty
     rng = RandomNumberGenerator(seed=42)
@@ -96,20 +96,19 @@ def test_OkSmall_multiple_vehicle_types(vehicle_types: List[VehicleType]):
 
 
 @mark.parametrize("seed", [2643, 2742, 2941, 3457, 4299, 4497, 6178, 6434])
-def test_RC208_instance(seed: int):
+def test_RC208_instance(rc208, seed: int):
     """
     Test a larger instance over several seeds.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=seed)
 
-    nb_params = NeighbourhoodParams(nb_granular=data.num_clients)
-    ls = LocalSearch(data, rng, compute_neighbours(data, nb_params))
-    ls.add_node_operator(TwoOpt(data))
+    nb_params = NeighbourhoodParams(nb_granular=rc208.num_clients)
+    ls = LocalSearch(rc208, rng, compute_neighbours(rc208, nb_params))
+    ls.add_node_operator(TwoOpt(rc208))
 
-    single_route = list(range(1, data.num_clients + 1))
-    sol = Solution(data, [single_route])
+    single_route = list(range(1, rc208.num_clients + 1))
+    sol = Solution(rc208, [single_route])
     improved_sol = ls.search(sol, cost_evaluator)
 
     # The new solution should strictly improve on our original solution.
@@ -118,17 +117,16 @@ def test_RC208_instance(seed: int):
     assert_(improved_cost < current_cost)
 
 
-def test_within_route_move():
+def test_within_route_move(ok_small):
     """
     Within-route 2-OPT reverses the segment between U and V. This test checks
     that such a move is correctly evaluated and applied on a single-route
     solution to the OkSmall instance.
     """
-    data = read("data/OkSmall.txt")
-    nodes = [Node(loc=loc) for loc in range(data.num_clients + 1)]
+    nodes = [Node(loc=loc) for loc in range(ok_small.num_clients + 1)]
 
     # Current route is 4 -> 1 -> 2 -> 3.
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
     route.append(nodes[4])
     route.append(nodes[1])
     route.append(nodes[2])
@@ -136,7 +134,7 @@ def test_within_route_move():
     route.update()
 
     cost_eval = CostEvaluator(1, 1)
-    two_opt = TwoOpt(data)
+    two_opt = TwoOpt(ok_small)
 
     # Current (relevant) part of the route has distance:
     #   dist(4, 1) + dist(1, 2) + dist(2, 3) + dist(3, 0)
diff --git a/pyvrp/search/tests/test_neighbourhood.py b/pyvrp/search/tests/test_neighbourhood.py
index bcac450..a824bbf 100644
--- a/pyvrp/search/tests/test_neighbourhood.py
+++ b/pyvrp/search/tests/test_neighbourhood.py
@@ -5,7 +5,6 @@ from numpy.testing import assert_, assert_equal, assert_raises
 from pytest import mark
 
 from pyvrp.search import NeighbourhoodParams, compute_neighbours
-from pyvrp.tests.helpers import read
 
 
 @mark.parametrize(
@@ -102,6 +101,7 @@ def test_neighbourhood_params_does_not_raise_for_valid_arguments(
     ],
 )
 def test_compute_neighbours(
+    rc208,
     weight_wait_time: int,
     weight_time_warp: int,
     nb_granular: int,
@@ -113,7 +113,6 @@ def test_compute_neighbours(
     """
     Tests ``compute_neighbours`` on several well-understood cases.
     """
-    data = read("data/RC208.txt", "solomon", "trunc1")
     params = NeighbourhoodParams(
         weight_wait_time,
         weight_time_warp,
@@ -121,9 +120,9 @@ def test_compute_neighbours(
         symmetric_proximity,
         symmetric_neighbours,
     )
-    neighbours = compute_neighbours(data, params)
+    neighbours = compute_neighbours(rc208, params)
 
-    assert_equal(len(neighbours), data.num_clients + 1)
+    assert_equal(len(neighbours), rc208.num_clients + 1)
     assert_equal(len(neighbours[0]), 0)
 
     # We compare sets because the expected data (from the old C++
@@ -137,68 +136,62 @@ def test_compute_neighbours(
             assert_equal(len(neighb), nb_granular)
 
 
-def test_neighbours_are_sorted_by_proximity():
+def test_neighbours_are_sorted_by_proximity(small_cvrp):
     """
     Tests that the neighbourhood lists sort by their proximity: closest first.
     """
-    # E-n22-k4 is a CVRP instance, so the time component is irrelevant.
-    data = read("data/E-n22-k4.txt", round_func="trunc1")
-    params = NeighbourhoodParams(0, 0, data.num_clients)
-    neighbours = compute_neighbours(data, params)
-    clients = list(range(1, data.num_clients + 1))
+    params = NeighbourhoodParams(0, 0, small_cvrp.num_clients)
+    neighbours = compute_neighbours(small_cvrp, params)
+    clients = list(range(1, small_cvrp.num_clients + 1))
 
     for client in clients:
         # Proximity is completely based on distance. We break ties by index
         # (using stable sort). The test below checks that this is the same as
         # what comes out of the granular neighbourhood calculation.
         valid = np.array([other for other in clients if other != client])
-        dists = [data.dist(client, other) for other in valid]
+        dists = [small_cvrp.dist(client, other) for other in valid]
         by_proximity = valid[np.argsort(dists, kind="stable")]
         assert_equal(by_proximity, neighbours[client])
 
 
-def test_symmetric_neighbours():
+def test_symmetric_neighbours(rc208):
     """
     Tests that when ``symmetric_neighbours`` is true, if an edge (i, j) is
     in the granular neighbourhood, then so is (j, i).
     """
-    data = read("data/RC208.txt", "solomon", "trunc1")
-
     # Symmetric neighbourhood structure: if (i, j) is in, then so is (j, i).
     params = NeighbourhoodParams(symmetric_neighbours=True)
-    sym_neighbours = [set(n) for n in compute_neighbours(data, params)]
+    sym_neighbours = [set(n) for n in compute_neighbours(rc208, params)]
 
-    for client in range(data.num_clients + 1):
+    for client in range(rc208.num_clients + 1):
         for neighbour in sym_neighbours[client]:
             assert_(client in sym_neighbours[neighbour])
 
     # But when neighbours are not symmetrised, this is typically not the case.
     params = NeighbourhoodParams(symmetric_neighbours=False)
-    asym_neighbours = [set(n) for n in compute_neighbours(data, params)]
+    asym_neighbours = [set(n) for n in compute_neighbours(rc208, params)]
     assert_(sym_neighbours != asym_neighbours)
 
 
-def test_more_neighbours_than_instance_size():
+def test_more_neighbours_than_instance_size(rc208):
     """
     Tests that a value for ``nb_granular`` larger than the problem instance's
     number of clients results in neighbourhoods of maximum size.
     """
-    data = read("data/RC208.txt", "solomon", round_func="trunc")
-    params = NeighbourhoodParams(nb_granular=data.num_clients)
-    neighbours = compute_neighbours(data, params)
+    params = NeighbourhoodParams(nb_granular=rc208.num_clients)
+    neighbours = compute_neighbours(rc208, params)
 
     for neighb in neighbours[1:]:
-        assert_equal(len(neighb), data.num_clients - 1)
+        assert_equal(len(neighb), rc208.num_clients - 1)
 
 
-def test_proximity_with_prizes():
+def test_proximity_with_prizes(prize_collecting):
     """
     Tests that prizes factor into the neighbourhood structure, and offset
     travel costs somewhat.
     """
-    data = read("data/p06-2-50.vrp", round_func="dimacs")
     params = NeighbourhoodParams(0, 0, nb_granular=10)
-    neighbours = compute_neighbours(data, params)
+    neighbours = compute_neighbours(prize_collecting, params)
 
     # We compare the number of times clients 20 and 36 are in other clients'
     # neighbourhoods. Client 20 is at location (57, 58), client 36 at (63, 69).
diff --git a/pyvrp/search/tests/test_primitives.py b/pyvrp/search/tests/test_primitives.py
index 1c45b94..85dfd56 100644
--- a/pyvrp/search/tests/test_primitives.py
+++ b/pyvrp/search/tests/test_primitives.py
@@ -3,38 +3,35 @@ from numpy.testing import assert_allclose
 
 from pyvrp import Client, CostEvaluator, ProblemData, VehicleType
 from pyvrp.search._search import Node, Route, insert_cost, remove_cost
-from pyvrp.tests.helpers import read
 
 
-def test_insert_cost_zero_when_not_allowed():
+def test_insert_cost_zero_when_not_allowed(ok_small):
     """
     Tests that insert_cost() returns zero when a move is not possible. This is
     the only case where it shortcuts to return a non-negative delta cost.
     """
-    data = read("data/OkSmall.txt")
     cost_eval = CostEvaluator(1, 1)
 
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
     for loc in [1, 2]:
         route.append(Node(loc=loc))
     route.update()
 
     # Inserting the depot is not possible.
-    assert_allclose(insert_cost(route[0], route[1], data, cost_eval), 0)
-    assert_allclose(insert_cost(route[3], route[2], data, cost_eval), 0)
+    assert_allclose(insert_cost(route[0], route[1], ok_small, cost_eval), 0)
+    assert_allclose(insert_cost(route[3], route[2], ok_small, cost_eval), 0)
 
     # Inserting after a node that's not in a route is not possible.
-    assert_allclose(insert_cost(route[1], Node(loc=3), data, cost_eval), 0)
+    assert_allclose(insert_cost(route[1], Node(loc=3), ok_small, cost_eval), 0)
 
 
-def test_insert_cost():
+def test_insert_cost(ok_small):
     """
     Tests that the insert_cost() method works correctly on a few basic cases.
     """
-    data = read("data/OkSmall.txt")
     cost_eval = CostEvaluator(1, 1)
 
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
     for loc in [1, 2]:
         route.append(Node(loc=loc))
     route.update()
@@ -44,11 +41,15 @@ def test_insert_cost():
     # demand, which exceeds the vehicle capacity by 5, so we get an additional
     # load penalty of 5. There is no time warp. Total delta cost:
     #     1593 + 1090 - 1992 + 5 = 696.
-    assert_allclose(insert_cost(Node(loc=4), route[1], data, cost_eval), 696)
+    assert_allclose(
+        insert_cost(Node(loc=4), route[1], ok_small, cost_eval), 696
+    )
 
     # +5 load penalty, and delta dist is 1090 + 1475 - 1965 = 600. So total
     # delta cost is 605 (again no time warp).
-    assert_allclose(insert_cost(Node(loc=4), route[2], data, cost_eval), 605)
+    assert_allclose(
+        insert_cost(Node(loc=4), route[2], ok_small, cost_eval), 605
+    )
 
     # Now we do have some time warp changes. +3 load penalty, delta dist is
     # 1427 + 647 - 1992 = 82, but time warp increases because we cannot get to
@@ -58,7 +59,9 @@ def test_insert_cost():
     # closing time window of 15300. So we add 17387 - 15300 = 2087 time warp.
     # Tallying it all up, total delta cost:
     #      3 + 82 + 2087 = 2172.
-    assert_allclose(insert_cost(Node(loc=3), route[1], data, cost_eval), 2172)
+    assert_allclose(
+        insert_cost(Node(loc=3), route[1], ok_small, cost_eval), 2172
+    )
 
     # +3 load penalty, delta dist is 621 + 2063 - 1965 = 719. Time warp
     # increases because we have to visit clients 1 and 2 before we visit
@@ -67,49 +70,49 @@ def test_insert_cost():
     # we arrive at 18933, after its closing time window of 15300. This adds
     # 18933 - 15300 = 3633 time warp. Tallying it all up, total delta cost:
     #     3 + 719 + 3633 = 4355.
-    assert_allclose(insert_cost(Node(loc=3), route[2], data, cost_eval), 4355)
+    assert_allclose(
+        insert_cost(Node(loc=3), route[2], ok_small, cost_eval), 4355
+    )
 
 
-def test_remove_cost_zero_when_not_allowed():
+def test_remove_cost_zero_when_not_allowed(ok_small):
     """
     Tests that remove_cost() returns zero when a move is not possible. This is
     the only case where it shortcuts to return a non-negative delta cost.
     """
-    data = read("data/OkSmall.txt")
     cost_eval = CostEvaluator(1, 1)
 
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
     for loc in [1, 2]:
         route.append(Node(loc=loc))
     route.update()
 
     # Removing the depot is not possible.
-    assert_allclose(remove_cost(route[0], data, cost_eval), 0)
-    assert_allclose(remove_cost(route[3], data, cost_eval), 0)
+    assert_allclose(remove_cost(route[0], ok_small, cost_eval), 0)
+    assert_allclose(remove_cost(route[3], ok_small, cost_eval), 0)
 
     # Removing a node that's not in a route is not possible.
-    assert_allclose(remove_cost(Node(loc=3), data, cost_eval), 0)
+    assert_allclose(remove_cost(Node(loc=3), ok_small, cost_eval), 0)
 
 
-def test_remove():
+def test_remove(ok_small):
     """
     Tests that the remove_cost() method works correctly on a few basic cases.
     """
-    data = read("data/OkSmall.txt")
     cost_eval = CostEvaluator(1, 1)
 
-    route = Route(data, idx=0, vehicle_type=0)
+    route = Route(ok_small, idx=0, vehicle_type=0)
     for loc in [1, 2]:
         route.append(Node(loc=loc))
     route.update()
 
     # Purely distance. Removes arcs 0 -> 1 -> 2, adds arc 0 -> 2. This change
     # has delta distance of 1944 - 1544 - 1992 = -1592.
-    assert_allclose(remove_cost(route[1], data, cost_eval), -1592)
+    assert_allclose(remove_cost(route[1], ok_small, cost_eval), -1592)
 
     # Purely distance. Removes arcs 1 -> 2 -> 0, adds arcs 1 -> 0. This change
     # has delta distance of 1726 - 1992 - 1965 = -2231.
-    assert_allclose(remove_cost(route[2], data, cost_eval), -2231)
+    assert_allclose(remove_cost(route[2], ok_small, cost_eval), -2231)
 
 
 def test_insert_fixed_vehicle_cost():
diff --git a/pyvrp/tests/fixtures.py b/pyvrp/tests/fixtures.py
new file mode 100644
index 0000000..4d664e1
--- /dev/null
+++ b/pyvrp/tests/fixtures.py
@@ -0,0 +1,51 @@
+import pytest
+
+from pyvrp.tests.helpers import read
+
+
+@pytest.fixture(scope="session")
+def ok_small():
+    """
+    Fixture that returns the OkSmall instance when called, an instance with
+    just 4 clients that's easy to understand.
+    """
+    return read("data/OkSmall.txt")
+
+
+@pytest.fixture(scope="session")
+def ok_small_prizes():
+    """
+    Fixture that returns the OkSmall instance when called, adapted for prize
+    collecting and optional customers.
+    """
+    return read("data/OkSmallPrizes.txt")
+
+
+@pytest.fixture(scope="session")
+def rc208():
+    """
+    Fixture that returns the RC208 VRPTW instance when called, with 100
+    clients.
+    """
+    return read(
+        "data/RC208.txt",
+        instance_format="solomon",
+        round_func="dimacs",
+    )
+
+
+@pytest.fixture(scope="session")
+def prize_collecting():
+    """
+    Fixture that returns a prize-collecting instance with 50 clients when
+    called.
+    """
+    return read("data/p06-2-50.vrp", round_func="dimacs")
+
+
+@pytest.fixture(scope="session")
+def small_cvrp():
+    """
+    Fixture that returns a small CVRP instance with just 21 clients.
+    """
+    return read("data/E-n22-k4.txt", round_func="dimacs")
diff --git a/pyvrp/tests/test_CostEvaluator.py b/pyvrp/tests/test_CostEvaluator.py
index 02bf9f5..dff8785 100644
--- a/pyvrp/tests/test_CostEvaluator.py
+++ b/pyvrp/tests/test_CostEvaluator.py
@@ -5,7 +5,7 @@ from numpy.testing import assert_, assert_allclose
 from pytest import mark
 
 from pyvrp import CostEvaluator, Route, Solution, VehicleType
-from pyvrp.tests.helpers import make_heterogeneous, read
+from pyvrp.tests.helpers import make_heterogeneous
 
 
 def test_load_penalty():
@@ -74,23 +74,22 @@ def test_tw_penalty():
     assert_allclose(cost_evaluator.tw_penalty(2), 8)
 
 
-def test_cost():
+def test_cost(ok_small):
     """
     This test asserts that the cost is computed correctly for feasible
     solutions, and is a large value (representing infinity) for infeasible
     solutions.
     """
-    data = read("data/OkSmall.txt")
     default_cost_evaluator = CostEvaluator()
     cost_evaluator = CostEvaluator(20, 6)
 
-    feas_sol = Solution(data, [[1, 2], [3], [4]])  # feasible solution
+    feas_sol = Solution(ok_small, [[1, 2], [3], [4]])  # feasible solution
     distance = feas_sol.distance()
 
     assert_allclose(cost_evaluator.cost(feas_sol), distance)
     assert_allclose(default_cost_evaluator.cost(feas_sol), distance)
 
-    infeas_sol = Solution(data, [[1, 2, 3, 4]])  # infeasible solution
+    infeas_sol = Solution(ok_small, [[1, 2, 3, 4]])  # infeasible solution
     assert_(not infeas_sol.is_feasible())
 
     # The C++ code represents infinity using a relevant maximal value, which
@@ -104,12 +103,12 @@ def test_cost():
     assert_allclose(default_cost_evaluator.cost(infeas_sol), INFEAS_COST)
 
 
-def test_cost_with_prizes():
+def test_cost_with_prizes(prize_collecting):
     """
     When solving a prize-collecting instance, the cost is equal to the distance
     plus a prize term.
     """
-    data = read("data/p06-2-50.vrp", round_func="dimacs")
+    data = prize_collecting
     cost_evaluator = CostEvaluator(1, 1)
 
     sol = Solution(data, [[1, 2], [3, 4, 5]])
@@ -126,27 +125,26 @@ def test_cost_with_prizes():
     assert_allclose(sol.distance() + sol.uncollected_prizes(), cost)
 
 
-def test_penalised_cost():
+def test_penalised_cost(ok_small):
     """
     The penalised cost represents the smoothed objective, where constraint
     violations are priced in using penalty terms. It can be computed for both
     feasible and infeasible solutions. In case of the former, it is equal
     to the actual cost: the penalty terms are all zero.
     """
-    data = read("data/OkSmall.txt")
     penalty_capacity = 20
     penalty_tw = 6
     default_evaluator = CostEvaluator()
     cost_evaluator = CostEvaluator(penalty_capacity, penalty_tw)
 
-    feas = Solution(data, [[1, 2], [3], [4]])
+    feas = Solution(ok_small, [[1, 2], [3], [4]])
     assert_(feas.is_feasible())
 
     # For a feasible solution, cost and penalised_cost equal distance.
     assert_allclose(cost_evaluator.penalised_cost(feas), feas.distance())
     assert_allclose(default_evaluator.penalised_cost(feas), feas.distance())
 
-    infeas = Solution(data, [[1, 2, 3, 4]])
+    infeas = Solution(ok_small, [[1, 2, 3, 4]])
     assert_(not infeas.is_feasible())
 
     # Compute cost associated with violated constraints.
@@ -166,15 +164,14 @@ def test_penalised_cost():
     ("assignment", "expected"), [((0, 0), 0), ((0, 1), 10), ((1, 1), 20)]
 )
 def test_cost_with_fixed_vehicle_cost(
-    assignment: Tuple[int, int], expected: int
+    ok_small, assignment: Tuple[int, int], expected: int
 ):
     """
     Tests that the cost evaluator counts the fixed cost when determining the
     objective value of a solution.
     """
-    data = read("data/OkSmall.txt")
     data = make_heterogeneous(
-        data,
+        ok_small,
         # First vehicle type is free, second costs 10 per vehicle. The solution
         # should be able to track this.
         [VehicleType(10, 2, fixed_cost=0), VehicleType(10, 2, fixed_cost=10)],
diff --git a/pyvrp/tests/test_GeneticAlgorithm.py b/pyvrp/tests/test_GeneticAlgorithm.py
index f23ee2c..5df4ef5 100644
--- a/pyvrp/tests/test_GeneticAlgorithm.py
+++ b/pyvrp/tests/test_GeneticAlgorithm.py
@@ -17,7 +17,7 @@ from pyvrp.crossover import selective_route_exchange as srex
 from pyvrp.diversity import broken_pairs_distance as bpd
 from pyvrp.search import Exchange10, LocalSearch, compute_neighbours
 from pyvrp.stop import MaxIterations
-from pyvrp.tests.helpers import read, read_solution
+from pyvrp.tests.helpers import read_solution
 
 
 @mark.parametrize(
@@ -59,40 +59,38 @@ def test_params_constructor_does_not_raise_when_arguments_valid(
     assert_equal(params.nb_iter_no_improvement, nb_iter_no_improvement)
 
 
-def test_raises_when_no_initial_solutions():
+def test_raises_when_no_initial_solutions(rc208):
     """
     Tests that GeneticAlgorithm raises when no initial solutions are provided,
     since that is insufficient to do crossover.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     pen_manager = PenaltyManager()
     rng = RandomNumberGenerator(seed=42)
-    ls = LocalSearch(data, rng, compute_neighbours(data))
+    ls = LocalSearch(rc208, rng, compute_neighbours(rc208))
 
     pop = Population(bpd)
     assert_equal(len(pop), 0)
 
     with assert_raises(ValueError):
         # No initial solutions should raise.
-        GeneticAlgorithm(data, pen_manager, rng, pop, ls, srex, [])
+        GeneticAlgorithm(rc208, pen_manager, rng, pop, ls, srex, [])
 
     # One initial solution, so this should be OK.
-    sol = Solution.make_random(data, rng)
-    GeneticAlgorithm(data, pen_manager, rng, pop, ls, srex, [sol])
+    sol = Solution.make_random(rc208, rng)
+    GeneticAlgorithm(rc208, pen_manager, rng, pop, ls, srex, [sol])
 
 
-def test_initial_solutions_added_when_running():
+def test_initial_solutions_added_when_running(rc208):
     """
     Tests that GeneticAlgorithm adds initial solutions to the population
     when running the algorithm.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     pm = PenaltyManager()
     rng = RandomNumberGenerator(seed=42)
     pop = Population(bpd)
-    ls = LocalSearch(data, rng, compute_neighbours(data))
-    init = {Solution.make_random(data, rng) for _ in range(25)}
-    algo = GeneticAlgorithm(data, pm, rng, pop, ls, srex, init)
+    ls = LocalSearch(rc208, rng, compute_neighbours(rc208))
+    init = {Solution.make_random(rc208, rng) for _ in range(25)}
+    algo = GeneticAlgorithm(rc208, pm, rng, pop, ls, srex, init)
 
     algo.run(MaxIterations(0))
 
@@ -103,29 +101,28 @@ def test_initial_solutions_added_when_running():
     assert_equal(len(pop), 25)
 
 
-def test_initial_solutions_added_when_restarting():
+def test_initial_solutions_added_when_restarting(rc208):
     """
     Tests that GeneticAlgorithm clears the population and adds the initial
     solutions when restarting.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     pm = PenaltyManager()
     rng = RandomNumberGenerator(seed=42)
     pop = Population(bpd)
 
-    ls = LocalSearch(data, rng, compute_neighbours(data))
-    ls.add_node_operator(Exchange10(data))
+    ls = LocalSearch(rc208, rng, compute_neighbours(rc208))
+    ls.add_node_operator(Exchange10(rc208))
 
     # We use the best known solution as one of the initial solutions so that
     # there are no improving iterations.
-    init = {Solution(data, read_solution("data/RC208.sol"))}
-    init.update(Solution.make_random(data, rng) for _ in range(24))
+    init = {Solution(rc208, read_solution("data/RC208.sol"))}
+    init.update(Solution.make_random(rc208, rng) for _ in range(24))
 
     params = GeneticAlgorithmParams(
         repair_probability=0,
         nb_iter_no_improvement=100,
     )
-    algo = GeneticAlgorithm(data, pm, rng, pop, ls, srex, init, params=params)
+    algo = GeneticAlgorithm(rc208, pm, rng, pop, ls, srex, init, params=params)
 
     algo.run(MaxIterations(100))
 
@@ -139,25 +136,25 @@ def test_initial_solutions_added_when_restarting():
     assert_equal(len(pop), 26)
 
 
-def test_best_solution_improves_with_more_iterations():
+def test_best_solution_improves_with_more_iterations(rc208):
     """
     Tests that additional iterations result in better solutions. This is a
     smoke test that checks at least something's improving during the GA's
     execution.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     rng = RandomNumberGenerator(seed=42)
     pm = PenaltyManager()
     pop_params = PopulationParams()
     pop = Population(bpd, params=pop_params)
     init = [
-        Solution.make_random(data, rng) for _ in range(pop_params.min_pop_size)
+        Solution.make_random(rc208, rng)
+        for _ in range(pop_params.min_pop_size)
     ]
 
-    ls = LocalSearch(data, rng, compute_neighbours(data))
-    ls.add_node_operator(Exchange10(data))
+    ls = LocalSearch(rc208, rng, compute_neighbours(rc208))
+    ls.add_node_operator(Exchange10(rc208))
 
-    algo = GeneticAlgorithm(data, pm, rng, pop, ls, srex, init)
+    algo = GeneticAlgorithm(rc208, pm, rng, pop, ls, srex, init)
 
     initial_best = algo.run(MaxIterations(0)).best
     new_best = algo.run(MaxIterations(25)).best
@@ -169,22 +166,21 @@ def test_best_solution_improves_with_more_iterations():
     assert_(new_best.is_feasible())  # best must be feasible
 
 
-def test_best_initial_solution():
+def test_best_initial_solution(rc208):
     """
     Tests that GeneticAlgorithm uses the best initial solution to initialise
     the best found solution.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     rng = RandomNumberGenerator(seed=42)
     pm = PenaltyManager()
     pop = Population(bpd)
 
-    init = [Solution.make_random(data, rng) for _ in range(24)]
-    bks = Solution(data, read_solution("data/RC208.sol"))
+    init = [Solution.make_random(rc208, rng) for _ in range(24)]
+    bks = Solution(rc208, read_solution("data/RC208.sol"))
     init.append(bks)
 
-    ls = LocalSearch(data, rng, compute_neighbours(data))
-    algo = GeneticAlgorithm(data, pm, rng, pop, ls, srex, init)
+    ls = LocalSearch(rc208, rng, compute_neighbours(rc208))
+    algo = GeneticAlgorithm(rc208, pm, rng, pop, ls, srex, init)
 
     result = algo.run(MaxIterations(0))
 
@@ -193,19 +189,18 @@ def test_best_initial_solution():
     assert_equal(result.best, bks)
 
 
-def test_infeasible_offspring_is_repaired():
+def test_infeasible_offspring_is_repaired(rc208):
     """
     Tests that infeasible offspring will be repaired if the repair probability
     is 1.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
-    bks = Solution(data, read_solution("data/RC208.sol"))
+    bks = Solution(rc208, read_solution("data/RC208.sol"))
 
     pm = PenaltyManager()
     rng = RandomNumberGenerator(seed=42)
     pop = Population(bpd)
 
-    init = [Solution.make_random(data, rng) for _ in range(25)]
+    init = [Solution.make_random(rc208, rng) for _ in range(25)]
     params = GeneticAlgorithmParams(repair_probability=1.0)
 
     def search(sol, cost_eval):
@@ -217,30 +212,29 @@ def test_infeasible_offspring_is_repaired():
     # We should have repaired at least once, since we start with a large
     # solution of random (mostly infeasible) solutions. Since we return the
     # BKS in that case, the resulting best solution should be the BKS as well.
-    algo = GeneticAlgorithm(data, pm, rng, pop, search, srex, init, params)
+    algo = GeneticAlgorithm(rc208, pm, rng, pop, search, srex, init, params)
     res = algo.run(stop=MaxIterations(25))
     assert_equal(res.best, bks)
 
 
-def test_never_repairs_when_zero_repair_probability():
+def test_never_repairs_when_zero_repair_probability(rc208):
     """
     Tests that the genetic algorithm does not repair when the repair
     probability parameter is set to zero.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     rng = RandomNumberGenerator(seed=42)
     pm = PenaltyManager(PenaltyParams(repair_booster=10))
     pop = Population(bpd)
 
-    ls = LocalSearch(data, rng, compute_neighbours(data))
-    ls.add_node_operator(Exchange10(data))
+    ls = LocalSearch(rc208, rng, compute_neighbours(rc208))
+    ls.add_node_operator(Exchange10(rc208))
 
-    init = [Solution.make_random(data, rng) for _ in range(25)]
+    init = [Solution.make_random(rc208, rng) for _ in range(25)]
 
     # Repair probability 100%, but we're still using the unpatched penalty
     # manager. This should be OK.
     ga_params = GeneticAlgorithmParams(repair_probability=1.0)
-    algo = GeneticAlgorithm(data, pm, rng, pop, ls, srex, init, ga_params)
+    algo = GeneticAlgorithm(rc208, pm, rng, pop, ls, srex, init, ga_params)
     algo.run(MaxIterations(50))
 
     # Now we patch the penalty manager: when asked for a booster cost evaluator
@@ -256,5 +250,5 @@ def test_never_repairs_when_zero_repair_probability():
     # But when we set the repair probability to 0%, the booster is no longer
     # needed, and nothing should raise.
     ga_params = GeneticAlgorithmParams(repair_probability=0.0)
-    algo = GeneticAlgorithm(data, pm, rng, pop, ls, srex, init, ga_params)
+    algo = GeneticAlgorithm(rc208, pm, rng, pop, ls, srex, init, ga_params)
     algo.run(MaxIterations(50))
diff --git a/pyvrp/tests/test_Model.py b/pyvrp/tests/test_Model.py
index 37902d6..98a8b45 100644
--- a/pyvrp/tests/test_Model.py
+++ b/pyvrp/tests/test_Model.py
@@ -10,7 +10,6 @@ from pyvrp import Model
 from pyvrp.constants import MAX_USER_VALUE, MAX_VALUE
 from pyvrp.exceptions import EmptySolutionWarning, ScalingWarning
 from pyvrp.stop import MaxIterations
-from pyvrp.tests.helpers import read
 
 
 def test_model_data():
@@ -170,55 +169,50 @@ def test_get_vehicle_types():
     assert_equal(model.vehicle_types[1], vehicle_type2)
 
 
-def test_from_data():
+def test_from_data(small_cvrp):
     """
     Tests that initialising the model from a data instance results in a valid
     model representation of that data instance.
     """
-    read_data = read("data/E-n22-k4.txt", round_func="dimacs")
-    model = Model.from_data(read_data)
+    model = Model.from_data(small_cvrp)
     model_data = model.data()
 
     # We can first check if the overall problem dimension numbers agree.
-    assert_equal(model_data.num_clients, read_data.num_clients)
-    assert_equal(model_data.num_vehicles, read_data.num_vehicles)
+    assert_equal(model_data.num_clients, small_cvrp.num_clients)
+    assert_equal(model_data.num_vehicles, small_cvrp.num_vehicles)
     assert_equal(
-        model_data.vehicle_type(0).capacity, read_data.vehicle_type(0).capacity
+        model_data.vehicle_type(0).capacity,
+        small_cvrp.vehicle_type(0).capacity,
     )
 
     # It's a bit cumbersome to compare the whole matrices, so we use a few
     # sample traces from the distance and duration matrices instead.
-    assert_equal(model_data.dist(3, 4), read_data.dist(3, 4))
-    assert_equal(model_data.duration(2, 1), read_data.duration(2, 1))
+    assert_equal(model_data.dist(3, 4), small_cvrp.dist(3, 4))
+    assert_equal(model_data.duration(2, 1), small_cvrp.duration(2, 1))
 
 
-def test_from_data_and_solve():
+def test_from_data_and_solve(small_cvrp, ok_small):
     """
     Tests that solving a model initialised from a data instance finds the
     correct (known) solutions.
     """
-    # Solve the small E-n22-k4 instance using the from_data constructor.
-    data = read("data/E-n22-k4.txt", round_func="dimacs")
-    model = Model.from_data(data)
+    model = Model.from_data(small_cvrp)
     res = model.solve(stop=MaxIterations(100), seed=0)
     assert_equal(res.cost(), 3_743)
     assert_(res.is_feasible())
 
-    data = read("data/OkSmall.txt")
-    model = Model.from_data(data)
+    model = Model.from_data(ok_small)
     res = model.solve(stop=MaxIterations(100), seed=0)
     assert_equal(res.cost(), 9_155)
     assert_(res.is_feasible())
 
 
-def test_model_and_solve():
+def test_model_and_solve(ok_small):
     """
     Tests that solving a model initialised using the modelling interface
     finds the correct (known) solutions.
     """
-    # Solve the small OkSmall instance using the from_data constructor.
-    data = read("data/OkSmall.txt")
-    model = Model.from_data(data)
+    model = Model.from_data(ok_small)
     res = model.solve(stop=MaxIterations(100), seed=0)
     assert_equal(res.cost(), 9_155)
     assert_(res.is_feasible())
diff --git a/pyvrp/tests/test_Population.py b/pyvrp/tests/test_Population.py
index 195920f..a6d10c9 100644
--- a/pyvrp/tests/test_Population.py
+++ b/pyvrp/tests/test_Population.py
@@ -17,7 +17,6 @@ from pyvrp import (
 )
 from pyvrp.diversity import broken_pairs_distance as bpd
 from pyvrp.exceptions import EmptySolutionWarning
-from pyvrp.tests.helpers import read
 
 
 @mark.parametrize(
@@ -107,20 +106,19 @@ def test_params_constructor_does_not_raise_when_arguments_valid(
     assert_equal(params.max_pop_size, min_pop_size + generation_size)
 
 
-def test_add_triggers_purge():
+def test_add_triggers_purge(ok_small):
     """
     Tests that adding another solution to a population of maximum size triggers
     survivor selection, that is, a purge that reduces the relevant population
     back to minimum size.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
     params = PopulationParams()
     pop = Population(bpd, params=params)
     for _ in range(params.min_pop_size):
-        pop.add(Solution.make_random(data, rng), cost_evaluator)
+        pop.add(Solution.make_random(ok_small, rng), cost_evaluator)
 
     # Population should initialise at least min_pop_size solutions
     assert_(len(pop) >= params.min_pop_size)
@@ -130,7 +128,7 @@ def test_add_triggers_purge():
     num_infeas = pop.num_infeasible()
 
     while True:  # keep adding feasible solutions until we are about to purge
-        sol = Solution.make_random(data, rng)
+        sol = Solution.make_random(ok_small, rng)
 
         if sol.is_feasible():
             pop.add(sol, cost_evaluator)
@@ -147,7 +145,7 @@ def test_add_triggers_purge():
     # trigger survivor selection (purge). Survivor selection reduces the
     # feasible subpopulation to min_pop_size, so the overal population is then
     # just num_infeas + min_pop_size.
-    sol = Solution.make_random(data, rng)
+    sol = Solution.make_random(ok_small, rng)
     assert_(sol.is_feasible())
 
     pop.add(sol, cost_evaluator)
@@ -155,12 +153,11 @@ def test_add_triggers_purge():
     assert_equal(len(pop), num_infeas + params.min_pop_size)
 
 
-def test_select_returns_same_parents_if_no_other_option():
+def test_select_returns_same_parents_if_no_other_option(ok_small):
     """
     Tests that the ``select()`` method tries to return two different parents,
     but will return the same solution twice if there is no other option.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=2_147_483_647)
 
@@ -169,7 +166,7 @@ def test_select_returns_same_parents_if_no_other_option():
 
     assert_equal(len(pop), 0)
 
-    pop.add(Solution(data, [[3, 2], [1, 4]]), cost_evaluator)
+    pop.add(Solution(ok_small, [[3, 2], [1, 4]]), cost_evaluator)
     assert_equal(len(pop), 1)
 
     # We added a single solution, so we should now get the same parent twice.
@@ -177,7 +174,7 @@ def test_select_returns_same_parents_if_no_other_option():
     assert_(parents[0] == parents[1])
 
     # Now we add another, different solution.
-    pop.add(Solution(data, [[3, 2], [1], [4]]), cost_evaluator)
+    pop.add(Solution(ok_small, [[3, 2], [1], [4]]), cost_evaluator)
     assert_equal(len(pop), 2)
 
     # We should now get two different solutions as parents, at least most of
@@ -196,13 +193,12 @@ def test_select_returns_same_parents_if_no_other_option():
     assert_(900 < different_parents < 1_000)
 
 
-def test_population_is_empty_with_zero_min_pop_size_and_generation_size():
+def test_pop_is_empty_with_zero_min_pop_size_and_generation_size(ok_small):
     """
     Tests that the population can never grow when it starts empty and the
     generation size is set to 0: adding a new solution then immediately
     triggers survivor selection.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=12)
 
@@ -215,18 +211,17 @@ def test_population_is_empty_with_zero_min_pop_size_and_generation_size():
         # With zero min_pop_size and zero generation_size, every additional
         # solution triggers a purge. So the population size can never grow
         # beyond zero.
-        pop.add(Solution.make_random(data, rng), cost_evaluator)
+        pop.add(Solution.make_random(ok_small, rng), cost_evaluator)
         assert_equal(len(pop), 0)
 
 
 @mark.parametrize("nb_elite", [5, 25])
-def test_elite_solutions_are_not_purged(nb_elite: int):
+def test_elite_solutions_are_not_purged(rc208, nb_elite: int):
     """
     Tests that elite solutions - those considered of such high quality that
     they should be given special treatment - are not purged during survivor
     selection.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     params = PopulationParams(nb_elite=nb_elite)
     rng = RandomNumberGenerator(seed=42)
@@ -236,7 +231,7 @@ def test_elite_solutions_are_not_purged(nb_elite: int):
     # Keep adding solutions until the infeasible subpopulation is of maximum
     # size.
     while pop.num_infeasible() != params.max_pop_size:
-        pop.add(Solution.make_random(data, rng), cost_evaluator)
+        pop.add(Solution.make_random(rc208, rng), cost_evaluator)
 
     assert_equal(pop.num_infeasible(), params.max_pop_size)
 
@@ -247,8 +242,8 @@ def test_elite_solutions_are_not_purged(nb_elite: int):
     elite_sols = best_sols[:nb_elite]
 
     # Add a solution that is certainly not feasible, thus causing a purge.
-    single_route = [client for client in range(1, data.num_clients + 1)]
-    pop.add(Solution(data, [single_route]), cost_evaluator)
+    single_route = [client for client in range(1, rc208.num_clients + 1)]
+    pop.add(Solution(rc208, [single_route]), cost_evaluator)
 
     # After the purge, there should remain min_pop_size infeasible solutions.
     assert_equal(pop.num_infeasible(), params.min_pop_size)
@@ -261,18 +256,17 @@ def test_elite_solutions_are_not_purged(nb_elite: int):
 
 
 @mark.parametrize("k", [2, 3])
-def test_tournament_ranks_by_fitness(k: int):
+def test_tournament_ranks_by_fitness(rc208, k: int):
     """
     Tests that the tournament-based parent selection on average returns
     solutions about as often as their relative fitness value would suggest.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
     pop = Population(bpd)
 
     for _ in range(50):
-        sol = Solution.make_random(data, rng)
+        sol = Solution.make_random(rc208, rng)
         if not sol.is_feasible():
             pop.add(sol, cost_evaluator)
 
@@ -311,41 +305,39 @@ def test_tournament_ranks_by_fitness(k: int):
 
 
 @mark.parametrize("k", [-100, -1, 0])  # k must be strictly positive
-def test_tournament_raises_for_invalid_k(k: int):
+def test_tournament_raises_for_invalid_k(rc208, k: int):
     """
     Tests that k >= 0 is required for tournament-based selection.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
     pop = Population(bpd)
     for _ in range(5):
-        pop.add(Solution.make_random(data, rng), cost_evaluator)
+        pop.add(Solution.make_random(rc208, rng), cost_evaluator)
 
     with assert_raises(ValueError):
         pop.get_tournament(rng, cost_evaluator, k=k)
 
 
-def test_purge_removes_duplicates():
+def test_purge_removes_duplicates(rc208):
     """
     Tests that purging/survivor selection first removes duplicate solutions,
     before purging by (biased) fitness.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     params = PopulationParams(min_pop_size=20, generation_size=5)
     rng = RandomNumberGenerator(seed=42)
 
     pop = Population(bpd, params=params)
     for _ in range(params.min_pop_size):
-        pop.add(Solution.make_random(data, rng), cost_evaluator)
+        pop.add(Solution.make_random(rc208, rng), cost_evaluator)
 
     assert_equal(len(pop), params.min_pop_size)
 
     # This is the solution we are going to add a few times. That should make
     # sure the relevant subpopulation definitely contains duplicates.
-    sol = Solution.make_random(data, rng)
+    sol = Solution.make_random(rc208, rng)
     assert_(not sol.is_feasible())
 
     for _ in range(params.generation_size):
@@ -370,17 +362,16 @@ def test_purge_removes_duplicates():
     assert_equal(duplicates, 1)
 
 
-def test_clear():
+def test_clear(rc208):
     """
     Tests that clearing the population reduces its size to zero.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
 
     pop = Population(bpd)
     for _ in range(10):
-        pop.add(Solution.make_random(data, rng), cost_evaluator)
+        pop.add(Solution.make_random(rc208, rng), cost_evaluator)
 
     assert_equal(len(pop), 10)
 
@@ -388,15 +379,14 @@ def test_clear():
     assert_equal(len(pop), 0)
 
 
-def test_add_emits_warning_when_solution_is_empty():
+def test_add_emits_warning_when_solution_is_empty(prize_collecting):
     """
     Tests that adding an empty solution to the population emits a warning. Such
     solutions could be generated when solving a prize-collecting instance, and
     typically indicate a scaling issue in the data.
     """
-    data = read("data/p06-2-50.vrp", round_func="dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     pop = Population(bpd)
 
     with assert_warns(EmptySolutionWarning):
-        pop.add(Solution(data, []), cost_evaluator)
+        pop.add(Solution(prize_collecting, []), cost_evaluator)
diff --git a/pyvrp/tests/test_ProblemData.py b/pyvrp/tests/test_ProblemData.py
index 19b9c65..ffe6789 100644
--- a/pyvrp/tests/test_ProblemData.py
+++ b/pyvrp/tests/test_ProblemData.py
@@ -4,7 +4,6 @@ from numpy.random import default_rng
 from numpy.testing import assert_, assert_allclose, assert_equal, assert_raises
 
 from pyvrp import Client, ProblemData, VehicleType
-from pyvrp.tests.helpers import read
 
 
 @pytest.mark.parametrize(
@@ -173,15 +172,13 @@ def test_problem_data_raises_when_incorrect_matrix_dimensions(matrix):
         ProblemData(clients, vehicle_types, other_matrix, matrix)
 
 
-def test_centroid():
+def test_centroid(ok_small):
     """
     Tests the computation of the centroid of all clients in the data instance.
     """
-    data = read("data/OkSmall.txt")
-
-    centroid = data.centroid()
-    x = [data.client(idx).x for idx in range(1, data.num_clients + 1)]
-    y = [data.client(idx).y for idx in range(1, data.num_clients + 1)]
+    centroid = ok_small.centroid()
+    x = [ok_small.client(idx).x for idx in range(1, ok_small.num_clients + 1)]
+    y = [ok_small.client(idx).y for idx in range(1, ok_small.num_clients + 1)]
 
     assert_allclose(centroid[0], np.mean(x))
     assert_allclose(centroid[1], np.mean(y))
diff --git a/pyvrp/tests/test_Result.py b/pyvrp/tests/test_Result.py
index 65a9e74..09de88a 100644
--- a/pyvrp/tests/test_Result.py
+++ b/pyvrp/tests/test_Result.py
@@ -8,21 +8,18 @@ from pyvrp import CostEvaluator, Population, RandomNumberGenerator, Solution
 from pyvrp.Result import Result
 from pyvrp.Statistics import Statistics
 from pyvrp.diversity import broken_pairs_distance
-from pyvrp.tests.helpers import read
 
 
 @mark.parametrize(
     ("routes", "num_iterations", "runtime"),
     [([[1, 2], [3], [4]], 1, 1.5), ([[1, 2, 3, 4]], 100, 54.2)],
 )
-def test_fields_are_correctly_set(routes, num_iterations, runtime):
+def test_fields_are_correctly_set(ok_small, routes, num_iterations, runtime):
     """
     Tests that ``Result``'s data properties are correctly set after
     initialisation completes.
     """
-    data = read("data/OkSmall.txt")
-    sol = Solution(data, routes)
-
+    sol = Solution(ok_small, routes)
     res = Result(sol, Statistics(), num_iterations, runtime)
 
     assert_equal(res.is_feasible(), sol.is_feasible())
@@ -42,23 +39,21 @@ def test_fields_are_correctly_set(routes, num_iterations, runtime):
         (0, -1.0),  # runtime < 0
     ],
 )
-def test_init_raises_invalid_arguments(num_iterations, runtime):
+def test_init_raises_invalid_arguments(ok_small, num_iterations, runtime):
     """
     Tests that invalid arguments are rejected.
     """
-    data = read("data/OkSmall.txt")
-    sol = Solution(data, [[1, 2, 3, 4]])
+    sol = Solution(ok_small, [[1, 2, 3, 4]])
 
     with assert_raises(ValueError):
         Result(sol, Statistics(), num_iterations, runtime)
 
 
 @mark.parametrize("num_iterations", [0, 1, 10])
-def test_num_iterations(num_iterations: int):
+def test_num_iterations(ok_small, num_iterations: int):
     """
     Tests access to the ``num_iterations`` property.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
     pop = Population(broken_pairs_distance)
@@ -67,7 +62,7 @@ def test_num_iterations(num_iterations: int):
     for _ in range(num_iterations):
         stats.collect_from(pop, cost_evaluator)
 
-    best = Solution.make_random(data, rng)
+    best = Solution.make_random(ok_small, rng)
     res = Result(best, stats, num_iterations, 0.0)
     assert_equal(res.num_iterations, num_iterations)
 
@@ -76,14 +71,12 @@ def test_num_iterations(num_iterations: int):
     "routes",
     [[[1, 2], [3], [4]], [[1, 2, 3, 4]]],
 )
-def test_str_contains_essential_information(routes):
+def test_str_contains_essential_information(ok_small, routes):
     """
     Tests that printing (or, in general, calling ```str(result)``) returns a
     bunch of useful information about the underlying solution.
     """
-    data = read("data/OkSmall.txt")
-
-    sol = Solution(data, routes)
+    sol = Solution(ok_small, routes)
     res = Result(sol, Statistics(), 0, 0.0)
     str_representation = str(res)
 
@@ -100,22 +93,21 @@ def test_str_contains_essential_information(routes):
 
 
 @mark.parametrize("num_iterations", [0, 1, 10])
-def test_result_can_be_pickled(num_iterations: int):
+def test_result_can_be_pickled(ok_small, num_iterations: int):
     """
     Tests that a ``Result`` object can be pickled: it can be serialised and
     unserialised. This is useful for e.g. storing results to disk.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
     pop = Population(broken_pairs_distance)
     stats = Statistics()
 
     for _ in range(num_iterations):
-        pop.add(Solution.make_random(data, rng), cost_evaluator)
+        pop.add(Solution.make_random(ok_small, rng), cost_evaluator)
         stats.collect_from(pop, cost_evaluator)
 
-    best = Solution(data, [[1, 2], [3], [4]])
+    best = Solution(ok_small, [[1, 2], [3], [4]])
 
     before_pickle = Result(best, stats, num_iterations, 1.2)
     bytes = pickle.dumps(before_pickle)
diff --git a/pyvrp/tests/test_Solution.py b/pyvrp/tests/test_Solution.py
index 579247f..1996ee7 100644
--- a/pyvrp/tests/test_Solution.py
+++ b/pyvrp/tests/test_Solution.py
@@ -17,24 +17,28 @@ from pyvrp import (
 from pyvrp.tests.helpers import make_heterogeneous, read
 
 
-def test_route_constructor_raises_for_empty_routes():
+@mark.parametrize(
+    "routes",
+    [
+        [[3, 4], [1, 2], []],
+        [[3, 4], [], [1, 2]],
+    ],
+)
+def test_route_constructor_raises_for_empty_routes(ok_small, routes):
     """
     Tests that constructing a ``Solution`` with empty routes fails.
     """
-    data = read("data/OkSmall.txt")
-
     with assert_raises(RuntimeError):
-        Solution(data, [[3, 4], [1, 2], []])
-    with assert_raises(RuntimeError):
-        Solution(data, [[3, 4], [], [1, 2]])
+        Solution(ok_small, routes)
 
 
-def test_route_constructor_with_different_vehicle_types():
+def test_route_constructor_with_different_vehicle_types(ok_small):
     """
     Tests that Solution's route constructor respects vehicle types.
     """
-    data = read("data/OkSmall.txt")
-    data = make_heterogeneous(data, [VehicleType(10, 1), VehicleType(20, 2)])
+    data = make_heterogeneous(
+        ok_small, [VehicleType(10, 1), VehicleType(20, 2)]
+    )
 
     sol = Solution(data, [Route(data, [3, 4], 0), Route(data, [1, 2], 1)])
 
@@ -51,12 +55,13 @@ def test_route_constructor_with_different_vehicle_types():
     assert_equal(routes[1], Route(data, [1, 2], 1))
 
 
-def test_route_eq():
+def test_route_eq(ok_small):
     """
     Tests ``Route``'s equality operator.
     """
-    data = read("data/OkSmall.txt")
-    data = make_heterogeneous(data, [VehicleType(10, 1), VehicleType(20, 2)])
+    data = make_heterogeneous(
+        ok_small, [VehicleType(10, 1), VehicleType(20, 2)]
+    )
 
     route1 = Route(data, [1, 2], 0)
     assert_(route1 == route1)  # should equal self
@@ -75,18 +80,17 @@ def test_route_eq():
     assert_(route1 != -1.0)
 
 
-def test_random_constructor_cycles_over_routes():
+def test_random_constructor_cycles_over_routes(ok_small):
     """
     Tests that a randomly constructed solution fills all available vehicles
     in turn.
     """
-    # This instance has four clients and three vehicles. Since 1 client per
-    # vehicle would not work (insufficient vehicles), each route is given two
-    # clients (and the last route should be empty).
-    data = read("data/OkSmall.txt")
+    # The OkSmall instance has four clients and three vehicles. Since 1 client
+    # per vehicle would not work (insufficient vehicles), each route is given
+    # two clients (and the last route should be empty).
     rng = RandomNumberGenerator(seed=42)
 
-    sol = Solution.make_random(data, rng)
+    sol = Solution.make_random(ok_small, rng)
     routes = sol.get_routes()
 
     assert_equal(sol.num_routes(), 2)
@@ -96,28 +100,28 @@ def test_random_constructor_cycles_over_routes():
         assert_equal(len(routes[idx]), size)
 
 
-def test_route_constructor_raises_too_many_vehicles():
+def test_route_constructor_raises_too_many_vehicles(ok_small):
     """
     Tests that constructing a solution with more routes than available in the
     data instance raises.
     """
-    data = read("data/OkSmall.txt")
-
-    assert_equal(data.num_vehicles, 3)
+    assert_equal(ok_small.num_vehicles, 3)
 
     # Only two routes should not raise.
-    sol = Solution(data, [[1, 2], [4, 3]])
+    sol = Solution(ok_small, [[1, 2], [4, 3]])
     assert_equal(len(sol.get_routes()), 2)
 
     # Three routes should not raise.
-    Solution(data, [[1, 2], [4], [3]])
+    Solution(ok_small, [[1, 2], [4], [3]])
 
     # More than three routes should raise, since we only have three vehicles.
     with assert_raises(RuntimeError):
-        Solution(data, [[1], [2], [3], [4]])
+        Solution(ok_small, [[1], [2], [3], [4]])
 
     # Now test the case with multiple vehicle types.
-    data = make_heterogeneous(data, [VehicleType(10, 2), VehicleType(20, 1)])
+    data = make_heterogeneous(
+        ok_small, [VehicleType(10, 2), VehicleType(20, 1)]
+    )
 
     # Only two routes (of type 0) should not raise.
     sol = Solution(data, [[1, 2], [4, 3]])
@@ -143,52 +147,50 @@ def test_route_constructor_raises_too_many_vehicles():
         Solution(data, [[1, 2], [4], [3]])
 
 
-def test_route_constructor_raises_when_clients_are_visited_more_than_once():
+def test_route_constructor_raises_for_multiple_visits(ok_small):
     """
     Tests that visiting the same client more than once raises.
     """
-    data = read("data/OkSmall.txt")
     with assert_raises(RuntimeError):
-        Solution(data, [[1, 2], [1, 3, 4]])  # client 1 is visited twice
+        Solution(ok_small, [[1, 2], [1, 3, 4]])  # client 1 is visited twice
 
     with assert_raises(RuntimeError):
-        Solution(data, [[1, 2], [1, 3, 4], [1]])  # client 1 is visited thrice
+        Solution(
+            ok_small, [[1, 2], [1, 3, 4], [1]]
+        )  # client 1 is visited thrice
 
 
-def test_route_constructor_allows_incomplete_solutions():
+def test_route_constructor_allows_incomplete_solutions(ok_small_prizes):
     """
     Tests that not visiting a client at all is allowed, but turns the solution
     infeasible (unless the client is not required). Allowing this is useful
     for both prize collecting, and in LNS settings where an incomplete solution
     is subsequently repaired.
     """
-    data = read("data/OkSmallPrizes.txt")
-
     # Client 1 is required but not visited.
-    sol = Solution(data, [[2], [3, 4]])
+    sol = Solution(ok_small_prizes, [[2], [3, 4]])
     assert_(not sol.is_complete())
     assert_(not sol.is_feasible())
 
     # All required clients are visited, but the solution is not feasible.
-    sol = Solution(data, [[1], [2, 3, 4]])
+    sol = Solution(ok_small_prizes, [[1], [2, 3, 4]])
     assert_(not sol.is_feasible())
     assert_(sol.is_complete())
 
     # All required clients are visited and the solution is feasible.
-    sol = Solution(data, [[1]])
+    sol = Solution(ok_small_prizes, [[1]])
     assert_(sol.is_feasible())
     assert_(sol.is_complete())
 
 
-def test_get_neighbours():
+def test_get_neighbours(ok_small):
     """
     Tests that accessing the neighbour structur of (pred, succ) pairs for each
     client in the solution works correctly.
     """
-    data = read("data/OkSmall.txt")
-    assert_equal(data.num_clients, 4)
+    assert_equal(ok_small.num_clients, 4)
 
-    sol = Solution(data, [[3], [1, 2]])  # client 4 is not in the solution
+    sol = Solution(ok_small, [[3], [1, 2]])  # client 4 is not in the solution
     assert_(not sol.is_complete())
 
     neighbours = sol.get_neighbours()
@@ -200,19 +202,17 @@ def test_get_neighbours():
         None,  # 4: unassigned
     ]
 
-    for client in range(data.num_clients + 1):  # incl. depot
+    for client in range(ok_small.num_clients + 1):  # incl. depot
         assert_equal(neighbours[client], expected[client])
 
 
-def test_feasibility():
+def test_feasibility(ok_small):
     """
     Tests that solutions are infeasible when they have load or time window
     violations.
     """
-    data = read("data/OkSmall.txt")
-
     # This solution is infeasible due to both load and time window violations.
-    sol = Solution(data, [[1, 2, 3, 4]])
+    sol = Solution(ok_small, [[1, 2, 3, 4]])
     assert_(not sol.is_feasible())
 
     # First route has total load 18, but vehicle capacity is only 10.
@@ -223,7 +223,7 @@ def test_feasibility():
     assert_(sol.has_time_warp())
 
     # Let's try another solution that's actually feasible.
-    sol = Solution(data, [[1, 2], [3], [4]])
+    sol = Solution(ok_small, [[1, 2], [3], [4]])
     assert_(sol.is_feasible())
     assert_(not sol.has_excess_load())
     assert_(not sol.has_time_warp())
@@ -253,14 +253,12 @@ def test_feasibility_release_times():
     assert_(sol.is_feasible())
 
 
-def test_distance_calculation():
+def test_distance_calculation(ok_small):
     """
     Tests that route distance calculations are correct, and that the overall
     Solution's distance is the sum of the route distances.
     """
-    data = read("data/OkSmall.txt")
-
-    sol = Solution(data, [[1, 2], [3], [4]])
+    sol = Solution(ok_small, [[1, 2], [3], [4]])
     routes = sol.get_routes()
 
     # Solution is feasible, so all its routes should also be feasible.
@@ -271,39 +269,36 @@ def test_distance_calculation():
     # check separately.
     assert_allclose(sol.distance(), sum(route.distance() for route in routes))
 
-    expected = data.dist(0, 1) + data.dist(1, 2) + data.dist(2, 0)
+    expected = ok_small.dist(0, 1) + ok_small.dist(1, 2) + ok_small.dist(2, 0)
     assert_allclose(routes[0].distance(), expected)
 
-    expected = data.dist(0, 3) + data.dist(3, 0)
+    expected = ok_small.dist(0, 3) + ok_small.dist(3, 0)
     assert_allclose(routes[1].distance(), expected)
 
-    expected = data.dist(0, 4) + data.dist(4, 0)
+    expected = ok_small.dist(0, 4) + ok_small.dist(4, 0)
     assert_allclose(routes[2].distance(), expected)
 
 
-def test_excess_load_calculation():
+def test_excess_load_calculation(ok_small):
     """
     Tests the Solution's excess load calculation on a single-route case.
     """
-    data = read("data/OkSmall.txt")
-
-    sol = Solution(data, [[4, 3, 1, 2]])
+    sol = Solution(ok_small, [[4, 3, 1, 2]])
     assert_(sol.has_excess_load())
     assert_(not sol.has_time_warp())
 
     # All clients are visited on the same route/by the same vehicle. The total
     # demand is 18, but the vehicle capacity is only 10.
-    assert_allclose(sol.excess_load(), 18 - data.vehicle_type(0).capacity)
+    assert_allclose(sol.excess_load(), 18 - ok_small.vehicle_type(0).capacity)
 
 
-def test_excess_load_calculation_with_multiple_vehicle_capacities():
+def test_excess_load_calculation_with_multiple_vehicle_capacities(ok_small):
     """
     Tests that vehicles of different capacities result in different (excess)
     load calaculations.
     """
-    data = read("data/OkSmall.txt")
     data = make_heterogeneous(
-        data, vehicle_types=[VehicleType(10, 2), VehicleType(20, 1)]
+        ok_small, vehicle_types=[VehicleType(10, 2), VehicleType(20, 1)]
     )
 
     # This instance has capacities 10 and 20 for vehicle type 0 and 1. The
@@ -319,12 +314,11 @@ def test_excess_load_calculation_with_multiple_vehicle_capacities():
     assert_allclose(sol.excess_load(), 0)
 
 
-def test_route_access_methods():
+def test_route_access_methods(ok_small):
     """
     Tests that accessing route statistics returns the correct numbers.
     """
-    data = read("data/OkSmall.txt")
-    sol = Solution(data, [[1, 3], [2, 4]])
+    sol = Solution(ok_small, [[1, 3], [2, 4]])
     routes = sol.get_routes()
 
     # Test route access: getting the route plan should return a simple list, as
@@ -338,7 +332,10 @@ def test_route_access_methods():
     assert_allclose(routes[1].excess_load(), 0)
 
     # Total route demand.
-    demands = [data.client(idx).demand for idx in range(data.num_clients + 1)]
+    demands = [
+        ok_small.client(idx).demand for idx in range(ok_small.num_clients + 1)
+    ]
+
     assert_allclose(routes[0].demand(), demands[1] + demands[3])
     assert_allclose(routes[1].demand(), demands[2] + demands[4])
 
@@ -349,19 +346,18 @@ def test_route_access_methods():
 
     # Total service duration.
     services = [
-        data.client(idx).service_duration
-        for idx in range(data.num_clients + 1)
+        ok_small.client(idx).service_duration
+        for idx in range(ok_small.num_clients + 1)
     ]
     assert_allclose(routes[0].service_duration(), services[1] + services[3])
     assert_allclose(routes[1].service_duration(), services[2] + services[4])
 
 
-def test_route_time_warp_calculations():
+def test_route_time_warp_calculations(ok_small):
     """
     Tests route time warp calculations.
     """
-    data = read("data/OkSmall.txt")
-    sol = Solution(data, [[1, 3], [2, 4]])
+    sol = Solution(ok_small, [[1, 3], [2, 4]])
     routes = sol.get_routes()
 
     # There is time warp on the first route: duration(0, 1) = 1'544, so we
@@ -428,19 +424,18 @@ def test_route_wait_time_calculations():
     )
 
 
-def test_route_start_and_end_time_calculations():
+def test_route_start_and_end_time_calculations(ok_small):
     """
     Tests route start time, slack, and end time calculations for cases with
     and without time warp or wait duration.
     """
-    data = read("data/OkSmall.txt")
-    sol = Solution(data, [[1, 3], [2, 4]])
+    sol = Solution(ok_small, [[1, 3], [2, 4]])
     routes = sol.get_routes()
 
     # The first route has timewarp, so there is no slack in the schedule. We
     # should thus depart as soon as possible to arrive at the first client the
     # moment its time window opens.
-    start_time = data.client(1).tw_early - data.duration(0, 1)
+    start_time = ok_small.client(1).tw_early - ok_small.duration(0, 1)
     end_time = start_time + routes[0].duration() - routes[0].time_warp()
 
     assert_(routes[0].has_time_warp())
@@ -567,15 +562,14 @@ def test_time_warp_return_to_depot():
     assert_allclose(sol.time_warp(), 1)
 
 
-def tests_that_not_specifying_the_vehicle_type_assumes_a_default():
+def tests_that_not_specifying_the_vehicle_type_assumes_a_default(ok_small):
     """
     Not specifying the vehicle type when providing a list of visits uses the
     first vehicle type to complete the routes. That could result in a solution
     using too many vehicles of the first type.
     """
-    data = read("data/OkSmall.txt")
     data = make_heterogeneous(
-        data, vehicle_types=[VehicleType(10, 2), VehicleType(20, 1)]
+        ok_small, vehicle_types=[VehicleType(10, 2), VehicleType(20, 1)]
     )
 
     sol = Solution(data, [[1, 2, 3, 4]])
@@ -607,14 +601,12 @@ def tests_that_not_specifying_the_vehicle_type_assumes_a_default():
         )
 
 
-def test_copy():
+def test_copy(ok_small):
     """
     Tests that copied solutions are equal to the original solution, but not
     the exact same object.
     """
-    data = read("data/OkSmall.txt")
-
-    sol = Solution(data, [[1, 2, 3, 4]])
+    sol = Solution(ok_small, [[1, 2, 3, 4]])
     copy_sol = copy(sol)
     deepcopy_sol = deepcopy(sol)
 
@@ -627,23 +619,21 @@ def test_copy():
     assert_(sol is not deepcopy_sol)
 
 
-def test_eq():
+def test_eq(ok_small):
     """
     Tests the solution's equality operator.
     """
-    data = read("data/OkSmall.txt")
-
-    sol1 = Solution(data, [[1, 2, 3, 4]])
-    sol2 = Solution(data, [[1, 2], [3], [4]])
-    sol3 = Solution(data, [[1, 2, 3, 4]])
+    sol1 = Solution(ok_small, [[1, 2, 3, 4]])
+    sol2 = Solution(ok_small, [[1, 2], [3], [4]])
+    sol3 = Solution(ok_small, [[1, 2, 3, 4]])
 
     assert_(sol1 == sol1)  # Solutions should be equal to themselves
     assert_(sol2 == sol2)
     assert_(sol1 != sol2)  # different routes, so should not be equal
     assert_(sol1 == sol3)  # same routes, different solution
 
-    sol4 = Solution(data, [[1, 2, 3], [4]])
-    sol5 = Solution(data, [[4], [1, 2, 3]])
+    sol4 = Solution(ok_small, [[1, 2, 3], [4]])
+    sol5 = Solution(ok_small, [[4], [1, 2, 3]])
 
     assert_(sol4 == sol5)  # routes are the same, but in different order
 
@@ -655,17 +645,16 @@ def test_eq():
     assert_(sol5 != "cd")
 
 
-def test_eq_with_multiple_vehicle_types():
+def test_eq_with_multiple_vehicle_types(ok_small):
     """
     Tests that two solutions are not considered equal if they have the same
     routes (orders of clients) but served by different vehicle types.
     """
-    data = read("data/OkSmall.txt")
     # Make sure capacities are different but large enough (>18) to have no
     # violations so have the same attributes, such that we actually test if the
     # assignments are used for the equality comparison.
     data = make_heterogeneous(
-        data, vehicle_types=[VehicleType(20, 2), VehicleType(30, 1)]
+        ok_small, vehicle_types=[VehicleType(20, 2), VehicleType(30, 1)]
     )
 
     # These two should be the same
@@ -711,13 +700,14 @@ def test_eq_unassigned():
     assert_(sol1 != sol3)
 
 
-def test_duplicate_vehicle_types():
+def test_duplicate_vehicle_types(ok_small):
     """
     Tests that it is allowed to have duplicate vehicle types. These will be
     considered completely different during optimisation.
     """
-    data = read("data/OkSmall.txt")
-    data = make_heterogeneous(data, [VehicleType(10, 1), VehicleType(10, 1)])
+    data = make_heterogeneous(
+        ok_small, [VehicleType(10, 1), VehicleType(10, 1)]
+    )
 
     sol1 = Solution(data, [Route(data, [1, 2, 3, 4], 0)])
     sol2 = Solution(data, [Route(data, [1, 2, 3, 4], 1)])
@@ -729,13 +719,11 @@ def test_duplicate_vehicle_types():
     "vehicle_types",
     [[VehicleType(10, 3)], [VehicleType(10, 2), VehicleType(20, 1)]],
 )
-def test_str_contains_routes(vehicle_types):
+def test_str_contains_routes(ok_small, vehicle_types):
     """
     Tests that the Solution's string representation contains each route.
     """
-    data = read("data/OkSmall.txt")
-    data = make_heterogeneous(data, vehicle_types)
-
+    data = make_heterogeneous(ok_small, vehicle_types)
     rng = RandomNumberGenerator(seed=2)
 
     for _ in range(5):  # let's do this a few times to really make sure
@@ -754,15 +742,14 @@ def test_str_contains_routes(vehicle_types):
                 assert_(str(client) in str_route)
 
 
-def test_hash():
+def test_hash(ok_small):
     """
     Tests that solutions that compare the same have the same hash.
     """
-    data = read("data/OkSmall.txt")
     rng = RandomNumberGenerator(seed=2)
 
-    sol1 = Solution.make_random(data, rng)
-    sol2 = Solution.make_random(data, rng)
+    sol1 = Solution.make_random(ok_small, rng)
+    sol2 = Solution.make_random(ok_small, rng)
 
     # Two random solutions. They're not the same, so the hashes should not be
     # the same either (unless there's a collision, which is not the case here).
@@ -776,16 +763,19 @@ def test_hash():
     assert_equal(hash(sol2), hash(sol3))
 
 
-def test_route_centroid():
+def test_route_centroid(ok_small):
     """
     Tests that each route's center point is the center point of all clients
     visited by that route.
     """
-    data = read("data/OkSmall.txt")
-    x = np.array([data.client(client).x for client in range(5)])
-    y = np.array([data.client(client).y for client in range(5)])
+    x = np.array([ok_small.client(client).x for client in range(5)])
+    y = np.array([ok_small.client(client).y for client in range(5)])
 
-    routes = [Route(data, [1, 2], 0), Route(data, [3], 0), Route(data, [4], 0)]
+    routes = [
+        Route(ok_small, [1, 2], 0),
+        Route(ok_small, [3], 0),
+        Route(ok_small, [4], 0),
+    ]
 
     for route in routes:
         x_center, y_center = route.centroid()
@@ -793,27 +783,25 @@ def test_route_centroid():
         assert_allclose(y_center, y[route].mean())
 
 
-def test_solution_can_be_pickled():
+def test_solution_can_be_pickled(ok_small):
     """
     Tests that a solution can be serialised and unserialised.
     """
-    data = read("data/OkSmall.txt")
     rng = RandomNumberGenerator(seed=2)
 
-    before_pickle = Solution.make_random(data, rng)
+    before_pickle = Solution.make_random(ok_small, rng)
     bytes = pickle.dumps(before_pickle)
     after_pickle = pickle.loads(bytes)
 
     assert_equal(after_pickle, before_pickle)
 
 
-def test_route_can_be_pickled():
+def test_route_can_be_pickled(rc208):
     """
     Tests that individual routes can be serialised and unserialised.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     rng = RandomNumberGenerator(seed=2)
-    sol = Solution.make_random(data, rng)
+    sol = Solution.make_random(rc208, rng)
 
     for before_pickle in sol.get_routes():
         bytes = pickle.dumps(before_pickle)
@@ -825,14 +813,15 @@ def test_route_can_be_pickled():
 @mark.parametrize(
     ("assignment", "expected"), [((0, 0), 0), ((0, 1), 10), ((1, 1), 20)]
 )
-def test_fixed_vehicle_cost(assignment: Tuple[int, int], expected: int):
+def test_fixed_vehicle_cost(
+    ok_small, assignment: Tuple[int, int], expected: int
+):
     """
     Tests that the solution tracks the total fixed vehicle costs of the
     vehicles used for its routes.
     """
-    data = read("data/OkSmall.txt")
     data = make_heterogeneous(
-        data,
+        ok_small,
         # First vehicle type is free, second costs 10 per vehicle. The solution
         # should be able to track this.
         [VehicleType(10, 2, fixed_cost=0), VehicleType(10, 2, fixed_cost=10)],
@@ -857,14 +846,15 @@ def test_fixed_vehicle_cost(assignment: Tuple[int, int], expected: int):
         (10_000, 20_000, 277),  # before earliest possible return
     ],
 )
-def test_route_shift_duration(tw_early: int, tw_late: int, expected: int):
+def test_route_shift_duration(
+    ok_small, tw_early: int, tw_late: int, expected: int
+):
     """
     Tests that Route computes time warp due to shift durations correctly on a
     simple, two-client route.
     """
-    data = read("data/OkSmall.txt")
     data = make_heterogeneous(
-        data, [VehicleType(10, 2, tw_early=tw_early, tw_late=tw_late)]
+        ok_small, [VehicleType(10, 2, tw_early=tw_early, tw_late=tw_late)]
     )
 
     # Overall route duration is, at the bare minimum, dist(0, 1) + dist(1, 2)
diff --git a/pyvrp/tests/test_Statistics.py b/pyvrp/tests/test_Statistics.py
index 100593f..f78babb 100644
--- a/pyvrp/tests/test_Statistics.py
+++ b/pyvrp/tests/test_Statistics.py
@@ -9,22 +9,20 @@ from pyvrp import (
     Statistics,
 )
 from pyvrp.diversity import broken_pairs_distance
-from pyvrp.tests.helpers import read
 
 
-def test_csv_serialises_correctly(tmp_path):
+def test_csv_serialises_correctly(ok_small, tmp_path):
     """
     Tests that writing a CSV of a ``Statistics`` object and then reading that
     CSV again returns the same object.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
     pop = Population(broken_pairs_distance)
 
     collected_stats = Statistics()
     for _ in range(10):  # populate the statistics object
-        pop.add(Solution.make_random(data, rng), cost_evaluator)
+        pop.add(Solution.make_random(ok_small, rng), cost_evaluator)
         collected_stats.collect_from(pop, cost_evaluator)
 
     csv_path = tmp_path / "test.csv"
@@ -42,19 +40,18 @@ def test_csv_serialises_correctly(tmp_path):
 
 
 @pytest.mark.parametrize("num_iterations", [0, 5])
-def test_stats_collects_a_data_point_per_iteration(num_iterations: int):
+def test_collect_a_data_point_per_iteration(ok_small, num_iterations: int):
     """
     Tests that the statistics object collects on feasible and infeasible data
     point every time ``collect_from`` is called.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
     pop = Population(broken_pairs_distance)
 
     stats = Statistics()
     for _ in range(num_iterations):  # populate the statistics object
-        pop.add(Solution.make_random(data, rng), cost_evaluator)
+        pop.add(Solution.make_random(ok_small, rng), cost_evaluator)
         stats.collect_from(pop, cost_evaluator)
 
     assert_equal(len(stats.feas_stats), num_iterations)
@@ -62,18 +59,17 @@ def test_stats_collects_a_data_point_per_iteration(num_iterations: int):
 
 
 @pytest.mark.parametrize("num_iterations", [0, 1, 10])
-def test_eq(num_iterations: int):
+def test_eq(ok_small, num_iterations: int):
     """
     Tests the equality operator.
     """
-    data = read("data/OkSmall.txt")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=42)
     pop = Population(broken_pairs_distance)
 
     stats = Statistics()
     for _ in range(num_iterations):  # populate the statistics object
-        pop.add(Solution.make_random(data, rng), cost_evaluator)
+        pop.add(Solution.make_random(ok_small, rng), cost_evaluator)
         stats.collect_from(pop, cost_evaluator)
 
     assert_equal(stats, stats)
diff --git a/pyvrp/tests/test_SubPopulation.py b/pyvrp/tests/test_SubPopulation.py
index ce55e76..6901556 100644
--- a/pyvrp/tests/test_SubPopulation.py
+++ b/pyvrp/tests/test_SubPopulation.py
@@ -10,16 +10,14 @@ from pyvrp import (
 )
 from pyvrp._pyvrp import SubPopulation
 from pyvrp.diversity import broken_pairs_distance as bpd
-from pyvrp.tests.helpers import read
 
 
 @mark.parametrize("nb_close", [5, 10, 25])
-def test_avg_distance_closest_is_same_up_to_nb_close(nb_close: int):
+def test_avg_distance_closest_is_same_up_to_nb_close(rc208, nb_close: int):
     """
     Tests that the average distance of a solution to other solutions only looks
     at the nearest ``nb_close`` solutions.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=5)
 
@@ -31,7 +29,7 @@ def test_avg_distance_closest_is_same_up_to_nb_close(nb_close: int):
     assert_equal(len(subpop), 0)
 
     for _ in range(nb_close):
-        subpop.add(Solution.make_random(data, rng), cost_evaluator)
+        subpop.add(Solution.make_random(rc208, rng), cost_evaluator)
 
     # The first nb_close solutions all have each other in their "closest"
     # list. The averages only differ because each solution is themselves not
@@ -43,7 +41,7 @@ def test_avg_distance_closest_is_same_up_to_nb_close(nb_close: int):
 
     # Let's add a significantly larger set of solutions.
     for _ in range(250 - nb_close):
-        subpop.add(Solution.make_random(data, rng), cost_evaluator)
+        subpop.add(Solution.make_random(rc208, rng), cost_evaluator)
 
     # Now the "closest" lists should differ quite a bit between solutions,
     # and the average distances should thus not all be the same any more.
@@ -52,32 +50,31 @@ def test_avg_distance_closest_is_same_up_to_nb_close(nb_close: int):
     assert_(not np.allclose(distances, distances.mean(), rtol=1 / len(subpop)))
 
 
-def test_avg_distance_closest_for_single_route_solutions():
+def test_avg_distance_closest_for_single_route_solutions(rc208):
     """
     Tests that the closest computations are exactly right for a simple, single
     route solution where it's easy to reason about what's going on.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     params = PopulationParams(min_pop_size=0, nb_close=10)
 
     subpop = SubPopulation(bpd, params)
     assert_equal(len(subpop), 0)
 
-    single_route = [client for client in range(1, data.num_clients + 1)]
+    single_route = [client for client in range(1, rc208.num_clients + 1)]
 
     for offset in range(params.max_pop_size):
         # This is a single-route solution, but the route is continually shifted
         # (or rotated) around the depot.
         shifted_route = single_route[-offset:] + single_route[:-offset]
-        shifted = Solution(data, [shifted_route])
+        shifted = Solution(rc208, [shifted_route])
 
         for item in subpop:
             # Every solution already in the subpopulation has exactly two
             # broken links with this new shifted solution, both around the
             # depot. So the average broken pairs distance is 2 / num_clients
             # for all of them.
-            assert_equal(bpd(item.solution, shifted), 2 / data.num_clients)
+            assert_equal(bpd(item.solution, shifted), 2 / rc208.num_clients)
 
         subpop.add(shifted, cost_evaluator)
         assert_equal(len(subpop), offset + 1)
@@ -89,19 +86,18 @@ def test_avg_distance_closest_for_single_route_solutions():
         assert_allclose(distances, distances.mean())
 
 
-def test_fitness_is_purely_based_on_cost_when_only_elites():
+def test_fitness_is_purely_based_on_cost_when_only_elites(rc208):
     """
     Tests than when all solutions are considered elite, the fitness values
     are completely determines by the solutions' cost.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=51)
     params = PopulationParams(nb_elite=25, min_pop_size=25)
     subpop = SubPopulation(bpd, params)
 
     for _ in range(params.min_pop_size):
-        subpop.add(Solution.make_random(data, rng), cost_evaluator)
+        subpop.add(Solution.make_random(rc208, rng), cost_evaluator)
 
     # We need to call update_fitness before accessing the fitness
     subpop.update_fitness(cost_evaluator)
@@ -125,19 +121,18 @@ def test_fitness_is_purely_based_on_cost_when_only_elites():
     assert_allclose(actual_fitness, expected_fitness)
 
 
-def test_fitness_is_average_of_cost_and_diversity_when_no_elites():
+def test_fitness_is_average_of_cost_and_diversity_when_no_elites(rc208):
     """
     When there are no elite solutions, the fitness ranking averages the cost
     and diversity rank.
     """
-    data = read("data/RC208.txt", "solomon", "dimacs")
     cost_evaluator = CostEvaluator(20, 6)
     rng = RandomNumberGenerator(seed=52)
     params = PopulationParams(nb_elite=0, min_pop_size=25)
     subpop = SubPopulation(bpd, params)
 
     for _ in range(params.min_pop_size):
-        subpop.add(Solution.make_random(data, rng), cost_evaluator)
+        subpop.add(Solution.make_random(rc208, rng), cost_evaluator)
 
     # We need to call update_fitness before accessing the fitness
     subpop.update_fitness(cost_evaluator)