Spring Boot Test Data: One JSON Fixture for @DataJpaTest and MockMvc
Spring Boot test data usually lives in three places at once. The
repository tests build entities with a long builder chain, a data.sql
or @Sql script inserts rows for the integration tests, and the
controller tests carry JSON strings typed into the test class. Each
describes the same customer, each drifts on its own schedule, and
together they usually cover one happy path.
This post replaces all three with one committed JSON fixture,
generated once from a JsonFabrica template with a fixed seed. The same
file is loaded with Jackson, persisted in @DataJpaTest, and posted
through MockMvc in @WebMvcTest and @SpringBootTest. Planted invalid
rows carry their own expected HTTP status, and the test run makes no
network call. Not on Spring? The same pattern is written up for
pytest and for
Jest and Vitest.
Why builder chains and data.sql drift
A typical Spring Boot project builds its test customers like this:
Customer customer = Customer.builder()
.name("Test User")
.email("[email protected]")
.tier(Tier.PRO)
.creditLimit(1000)
.active(true)
.build();
and seeds its integration tests like this:
INSERT INTO customer (name, email, tier, credit_limit, active)
VALUES ('Test User', '[email protected]', 'PRO', 1000, true);
Both work on the day they're written. Then the problems pile up:
- New columns go missing. Someone adds
signedUpOnto the entity. The builder leaves itnull, the SQL script never mentions it, and unless the column isNOT NULLevery test keeps passing. - One customer stands in for all of them.
Test Usernever hits the maximum credit limit, a duplicate email or an inactive account. Each edge case needs another builder call orINSERT, so most never get written. - The layers don't share anything. The builder, the SQL row and the controller test's JSON string are three copies. When the request shape changes, the copy you forget keeps testing the old shape.
The fix is one copy of the data that every test slice reads, with the awkward cases planted on purpose.
Spring Boot test fixtures in JSON: the layout
The example app has one endpoint, POST /api/customers, that validates
a signup request and stores a Customer entity. The project layout:
scripts/
regen-fixtures.sh calls the API; run by hand, not by the build
src/main/java/com/example/customers/
Customer.java @Entity
CustomerRequest.java request DTO with validation annotations
CustomerRepository.java
CustomerService.java
CustomerController.java
src/test/java/com/example/customers/
CustomerFixtures.java loads the JSON once, shared by every slice
CustomerFixturesTest.java checks the fixture's own invariants
CustomerRepositoryTest.java @DataJpaTest
CustomerControllerTest.java @WebMvcTest
CustomerSignupFlowTest.java @SpringBootTest
src/test/resources/fixtures/
customers.tmpl the template (committed)
customers.json the generated data (committed)
The build needs spring-boot-starter-web, spring-boot-starter-data-jpa
and spring-boot-starter-validation, plus spring-boot-starter-test
and H2 in test scope. Nothing JsonFabrica-specific goes into the build:
only the regen script talks to the API.
The code targets Spring Boot 3.4 or later on Java 17+, with Jackson 2 and JUnit Jupiter. Spring Boot 3.x is past open-source support, though: 3.4 ended on 2025-12-31 and 3.5 on 2026-06-30. On Spring Boot 4 the approach is the same, but the build and the imports change:
- Test starters:
spring-boot-starter-testno longer covers the slices. Addspring-boot-starter-data-jpa-testandspring-boot-starter-webmvc-test. - New packages:
org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest,org.springframework.boot.jpa.test.autoconfigure.TestEntityManager,org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase, andWebMvcTestandAutoConfigureMockMvcinorg.springframework.boot.webmvc.test.autoconfigure. @MockBeanis removed, not just deprecated. The@MockitoBeanused below works on both.- Jackson 3 is the default.
ObjectMapper,JsonNodeand the rest of databind move totools.jackson.*, andjava.timeis built in, soJavaTimeModuleisn't needed.JsonProcessingExceptionbecomes the uncheckedJacksonException, sotoRequestno longer needs its try/catch.
The code under test, with imports and getters left out:
public record CustomerRequest(
@NotBlank String name,
@NotBlank @Email String email,
@NotNull Tier tier,
@NotNull @Min(0) @Max(50_000) Integer creditLimit,
@NotNull LocalDate signedUpOn,
boolean active) {}
public enum Tier { BASIC, PRO, ENTERPRISE }
@Entity
public class Customer {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false) private String name;
@Column(nullable = false, unique = true) private String email;
@Enumerated(EnumType.STRING) private Tier tier;
private int creditLimit;
private LocalDate signedUpOn;
private boolean active;
protected Customer() {}
public static Customer from(CustomerRequest r) {
Customer c = new Customer();
c.name = r.name();
c.email = r.email();
c.tier = r.tier();
c.creditLimit = r.creditLimit();
c.signedUpOn = r.signedUpOn();
c.active = r.active();
return c;
}
}
public interface CustomerRepository extends JpaRepository<Customer, Long> {
boolean existsByEmail(String email);
List<Customer> findByTierAndActiveTrueOrderBySignedUpOnDescEmailAsc(
Tier tier);
}
@Service
public class CustomerService {
private final CustomerRepository customers;
public CustomerService(CustomerRepository customers) {
this.customers = customers;
}
@Transactional
public long create(CustomerRequest request) {
if (customers.existsByEmail(request.email())) {
throw new DuplicateEmailException(request.email()); // -> 409
}
return customers.save(Customer.from(request)).getId();
}
}
@RestController
@RequestMapping("/api/customers")
public class CustomerController {
private final CustomerService service;
public CustomerController(CustomerService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<Void> create(
@Valid @RequestBody CustomerRequest request) {
long id = service.create(request);
return ResponseEntity.created(URI.create("/api/customers/" + id))
.build();
}
}
DuplicateEmailException is a RuntimeException annotated with
@ResponseStatus(HttpStatus.CONFLICT).
Generate the fixture set with a fixed seed
Each fixture row is a request body plus the status the API should answer it with. The template produces 16 of them:
<setVar('takenEmail', getRandomEmail('example.com'))>
[<for(i, 1, getParam('rows', 16))>
<if(getVar('i') > 1)>,<endIf>
{
<if(getVar('i') == 2)>
"scenario": "credit limit at maximum", "expectedStatus": 201,
<elseIf(getVar('i') == 3)>
"scenario": "invalid email", "expectedStatus": 400,
<elseIf(getVar('i') == 4)>
"scenario": "missing name", "expectedStatus": 400,
<elseIf(getVar('i') == 5)>
"scenario": "credit limit too high", "expectedStatus": 400,
<elseIf(getVar('i') == 6)>
"scenario": "impossible date", "expectedStatus": 400,
<elseIf(getVar('i') == 7)>
"scenario": "duplicate email", "expectedStatus": 409,
<else>
"scenario": "valid", "expectedStatus": 201,
<endIf>
"body": {
<if(getVar('i') != 4)>"name": "<getRandomFullName()>",<endIf>
"email": <if(getVar('i') == 1 || getVar('i') == 7)>
"<getVar('takenEmail')>"
<elseIf(getVar('i') == 3)>"not-an-email"
<else>"<getRandomEmail('example.com')>"<endIf>,
"tier": <if(getVar('i') == 8)>"BASIC"
<elseIf(getVar('i') == 9)>"PRO"
<elseIf(getVar('i') == 10)>"ENTERPRISE"
<else>"<getRandomElement('BASIC', 'PRO', 'ENTERPRISE')>"<endIf>,
"creditLimit": <if(getVar('i') == 2)>50000
<elseIf(getVar('i') == 5)>50001
<else><getRandomNumber(0, 20000)><endIf>,
"signedUpOn": <if(getVar('i') == 6)>"2026-02-30"
<else>
"<formatDate(getRandomDate('2023-01-01', '2026-09-30'), 'YYYY-MM-DD')>"
<endIf>,
"active": <if(getVar('i') == 8 || getVar('i') == 9 || getVar('i') == 10)>true
<else><getRandomBoolean(0.8)><endIf>
}
}<end_for>
]
Save it as src/test/resources/fixtures/customers.tmpl. How it works:
- There's one variable, because one value is used twice. Row 1
and row 7 both print
takenEmail, so the duplicate-email row collides with a customer that really exists in the file.setVarproduces no output. Every other value is written inline in its field. - The planted rows sit at fixed positions. Rows 2 to 7 each carry
one oddity: a credit limit exactly at the
@Maxof 50,000, an email without an@, nonamekey at all, a limit one over the maximum, February 30th, and the duplicate email. Each branch of the firstifchain sets the scenario and its status together, so they can't get out of step. - Rows 8 to 10 cover every tier. They are fixed to
BASIC,PROandENTERPRISEand always active, so every seed has an active customer per tier for the repository test to find. Row 1 and rows 8 to 16 are ordinary valid signups. - "Missing" means the key is absent, not
null. Row 4 leaves the whole"name": ...,line out, comma included, so the JSON stays valid and the test sees what a client that never sends the field gets back. - Dates are plain dates.
getRandomDatereturns an ISO 8601 timestamp, andformatDatecuts it to theYYYY-MM-DDaLocalDateexpects. - The row count is a parameter.
getParamreadsrowsin the loop bound and defaults to 16. Below 10 drops the per-tier rows; below 7, planted scenarios too.
Template expressions have comparisons and &&/|| but no arithmetic,
so the planted rows are index checks. The comma guard
<if(getVar('i') > 1)>,<endIf> puts a comma before every row but the
first; the control flow docs cover for and
if/elseIf/else.
Rendered with seed 20261019 and the default 16 rows, rows 1, 4 and 7
came out like this:
{
"scenario": "valid",
"expectedStatus": 201,
"body": {
"name": "Robin Lloyd-Webber",
"email": "[email protected]",
"tier": "PRO",
"creditLimit": 5908,
"signedUpOn": "2026-07-24",
"active": true
}
},
{
"scenario": "missing name",
"expectedStatus": 400,
"body": {
"email": "[email protected]",
"tier": "PRO",
"creditLimit": 1977,
"signedUpOn": "2024-12-29",
"active": false
}
},
{
"scenario": "duplicate email",
"expectedStatus": 409,
"body": {
"name": "Branith Price",
"email": "[email protected]",
"tier": "PRO",
"creditLimit": 18882,
"signedUpOn": "2023-09-10",
"active": true
}
}
The regen script: curl, jq, and a committed file
The regen script is the only code that calls JsonFabrica. It sends the
template to
POST /v1/templates/generate, which
renders a template body without saving it, and writes
customers.json. CI doesn't need an API key to run the tests.
#!/usr/bin/env bash
# Regenerates src/test/resources/fixtures/customers.json from the
# template next to it. Run by hand, review the diff, commit both files.
# The build never runs this; tests read the committed JSON.
#
# scripts/regen-fixtures.sh # default seed
# scripts/regen-fixtures.sh 12345 # a specific seed
# scripts/regen-fixtures.sh random # let the API pick one
set -euo pipefail
SEED="${1:-20261019}" # change the default only to replace the data
ROWS="${ROWS:-16}"
DIR=src/test/resources/fixtures
API=https://api.jsonfabrica.com/v1/templates/generate
: "${JSONFABRICA_API_KEY:?set JSONFABRICA_API_KEY}"
# "abc" | tonumber fails here, so a mistyped seed never reaches the API.
request=$(jq -n \
--rawfile body "$DIR/customers.tmpl" \
--arg seed "$SEED" \
--argjson rows "$ROWS" \
'{body: $body, params: {rows: $rows}}
+ (if $seed == "random" then {} else {seed: ($seed | tonumber)} end)')
# --fail-with-body (curl 7.76+) exits non-zero on 4xx/5xx but still
# prints the {"error": {...}} envelope, e.g. INVALID_TEMPLATE.
if ! response=$(curl -sS --fail-with-body -X POST "$API" \
-H "Authorization: Bearer $JSONFABRICA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary "$request"); then
echo "$response" >&2
exit 1
fi
used=$(jq '.meta.seed' <<<"$response")
if [[ "$SEED" != random && "$used" != "$SEED" ]]; then
echo "API used seed $used, expected $SEED" >&2
exit 1
fi
# meta.generatedAt changes on every call, so it stays out of the file.
jq --argjson rows "$ROWS" \
'{seed: .meta.seed, params: {rows: $rows}, rows: .data}' \
<<<"$response" > "$DIR/customers.json"
echo "wrote $DIR/customers.json (seed $used, $ROWS rows)"
Run it from the project root with your API key set. The details that matter:
- The response is
{ data, meta }.datais the generated array andmeta.seedis the seed the API actually used. The script writes that seed and the params at the top ofcustomers.json, next torows, so the file says how to rebuild itself. - The seed check catches seeds that don't round-trip. The engine
reduces seeds to 32-bit unsigned integers, so
99999999999is accepted but rendered as a different number. The script stops before writing a file the given seed can't rebuild. - Same template, seed and params give the same file. Editing the template shifts the random draws, so commit the regenerated file in the same pull request and review its diff like code.
Load JSON test data with Jackson
One helper class reads the file once for every test slice. It uses
plain Jackson rather than Spring's ObjectMapper bean, because
@MethodSource factories are static.
// src/test/java/com/example/customers/CustomerFixtures.java
package com.example.customers;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.List;
/** Loads fixtures/customers.json once; every test slice reads it. */
final class CustomerFixtures {
/** One generated row: a request body and the status it must get. */
record Row(String scenario, int expectedStatus, JsonNode body) {}
/** The file the regen script writes. */
record FixtureFile(long seed, JsonNode params, List<Row> rows) {}
// Declared before FILE: static fields initialize in source order.
// JavaTimeModule reads LocalDate. FAIL_ON_UNKNOWN_PROPERTIES is
// enabled explicitly, so a field the DTO lacks fails the load.
static final ObjectMapper JSON = JsonMapper.builder()
.addModule(new JavaTimeModule())
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
private static final FixtureFile FILE = load("/fixtures/customers.json");
private CustomerFixtures() {}
static long seed() {
return FILE.seed();
}
static List<Row> rows() {
return FILE.rows();
}
/** The rows the API accepts, as the DTO the controller binds. */
static List<CustomerRequest> valid() {
return rows().stream()
.filter(row -> row.expectedStatus() == 201)
.map(row -> toRequest(row.body()))
.toList();
}
static Row scenario(String name) {
return rows().stream()
.filter(row -> row.scenario().equals(name))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(name));
}
static CustomerRequest toRequest(JsonNode body) {
try {
return JSON.treeToValue(body, CustomerRequest.class);
} catch (JsonProcessingException e) {
throw new UncheckedIOException(e);
}
}
private static FixtureFile load(String path) {
try (InputStream in = CustomerFixtures.class.getResourceAsStream(path)) {
if (in == null) {
throw new IllegalStateException(path + " is not on the classpath");
}
return JSON.readValue(in, FixtureFile.class);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
The choices that matter:
bodystays aJsonNode. Only valid rows are turned intoCustomerRequest. The invalid rows have to reach the controller exactly as written, and some can't exist as a DTO at all: there's noLocalDatefor February 30th.JavaTimeModuleis registered explicitly. Spring Boot's mapper registers it, but one you build yourself in Jackson 2 doesn't, and readingsignedUpOnwould fail with "Java 8 date/time typejava.time.LocalDatenot supported by default".- The mapper is stricter than the app's on purpose. Spring Boot
turns off
FAIL_ON_UNKNOWN_PROPERTIES, and Jackson 3 has it off by default. This mapper enables it explicitly, so if the template gains a fieldCustomerRequestdoesn't have,valid()fails with the field's name instead of dropping it.
A plain JUnit test with no Spring context checks what the other tests rely on, so a bad regeneration fails here first:
// src/test/java/com/example/customers/CustomerFixturesTest.java
// Imports: JUnit's Test and AssertJ's assertThat.
class CustomerFixturesTest {
@Test
void validRowsHaveUniqueEmails() {
assertThat(CustomerFixtures.valid())
.extracting(CustomerRequest::email)
.doesNotHaveDuplicates();
}
@Test
void everyPlantedScenarioIsPresent() {
assertThat(CustomerFixtures.rows())
.extracting(CustomerFixtures.Row::scenario)
.contains("credit limit at maximum", "invalid email",
"missing name", "credit limit too high", "impossible date",
"duplicate email");
}
@Test
void everyTierHasAnActiveCustomer() {
assertThat(CustomerFixtures.valid())
.filteredOn(CustomerRequest::active)
.extracting(CustomerRequest::tier)
.contains(Tier.values());
}
}
The tier check matters because the repository test below compares its
query against the fixture. With no active ENTERPRISE customer, both
sides would be an empty list and that test would pass without testing
anything.
DataJpaTest test data: persist with TestEntityManager
@DataJpaTest starts only the JPA side: entities, Spring Data
repositories, a DataSource and a TestEntityManager. By default it
replaces your configured DataSource with an embedded in-memory
database such as H2, unless that DataSource is already a test one
(for example, a Testcontainers database wired with
@ServiceConnection). Each test runs in a transaction that is rolled
back at the end.
// src/test/java/com/example/customers/CustomerRepositoryTest.java
package com.example.customers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Comparator;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.dao.DataIntegrityViolationException;
@DataJpaTest
class CustomerRepositoryTest {
@Autowired TestEntityManager em;
@Autowired CustomerRepository customers;
@BeforeEach
void loadFixture() {
CustomerFixtures.valid().forEach(r -> em.persist(Customer.from(r)));
em.flush(); // write everything now, so constraints are checked now
em.clear(); // later reads go to the database, not the session cache
}
@ParameterizedTest
@EnumSource(Tier.class)
void listsActiveCustomersOfATierNewestFirst(Tier tier) {
List<String> expected = CustomerFixtures.valid().stream()
.filter(c -> c.tier() == tier && c.active())
.sorted(Comparator.comparing(CustomerRequest::signedUpOn)
.reversed()
.thenComparing(CustomerRequest::email))
.map(CustomerRequest::email)
.toList();
List<String> actual = customers
.findByTierAndActiveTrueOrderBySignedUpOnDescEmailAsc(tier)
.stream()
.map(Customer::getEmail)
.toList();
assertThat(actual)
.as("fixture seed %d", CustomerFixtures.seed())
.isEqualTo(expected);
}
@Test
void rejectsASecondCustomerWithTheSameEmail() {
CustomerRequest duplicate = CustomerFixtures.toRequest(
CustomerFixtures.scenario("duplicate email").body());
assertThatThrownBy(() -> customers.saveAndFlush(Customer.from(duplicate)))
.isInstanceOf(DataIntegrityViolationException.class);
}
}
What this test relies on:
- The
@BeforeEachinserts are rolled back too. Spring runs before-each methods inside the test-managed transaction, so each test starts from the same 11 valid customers. flush()andclear()are not optional. Since the transaction never commits, a broken constraint might never reach the database. Flushing pushes the inserts out, and clearing makes the query read real rows instead of the objects you just persisted.- The expected result comes from the fixture. The test filters and
sorts the rows it inserted, so it survives a regeneration and fails
only when the query is wrong. The secondary sort on
emailcovers two customers sharing asignedUpOndate in some other seed. - The duplicate test uses the planted row. Row 7 shares row 1's
email, and row 1 is already inserted. Spring Data repositories
translate the unique-constraint failure into
DataIntegrityViolationException;TestEntityManagerdoesn't, so the test saves through the repository.
H2 is fast but isn't your production database. Point the slice at a
Testcontainers database with @ServiceConnection (or
@DynamicPropertySource) and Spring Boot 3.4+ leaves it in place; the
Testcontainers post covers starting
the container and loading data. Only a real database set through plain
properties needs @AutoConfigureTestDatabase(replace = Replace.NONE).
The same fixture loads unchanged either way.
MockMvc validation tests from the same file
@WebMvcTest loads the web layer only: controllers, @ControllerAdvice,
Jackson and validation. The service is a mock. A JUnit 5 @ParameterizedTest with
@MethodSource reads every row from the JSON file, posts it, and
checks the status.
// src/test/java/com/example/customers/CustomerControllerTest.java
package com.example.customers;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(CustomerController.class)
class CustomerControllerTest {
@Autowired MockMvc mvc;
@MockitoBean CustomerService service;
// A duplicate email needs a database to detect, so the 409 row is
// covered by CustomerSignupFlowTest instead.
static Stream<Arguments> requestBodies() {
return CustomerFixtures.rows().stream()
.filter(row -> row.expectedStatus() != 409)
.map(row -> Arguments.of(
row.scenario(), row.expectedStatus(), row.body()));
}
@ParameterizedTest(name = "[{index}] {0} -> {1}")
@MethodSource("requestBodies")
void answersEachRowWithItsExpectedStatus(
String scenario, int expectedStatus, JsonNode body) throws Exception {
mvc.perform(post("/api/customers")
.contentType(MediaType.APPLICATION_JSON)
.content(body.toString()))
.andExpect(status().is(expectedStatus));
if (expectedStatus == 201) {
verify(service).create(CustomerFixtures.toRequest(body));
} else {
verifyNoInteractions(service);
}
}
}
The report lists one entry per row, such as [3] invalid email -> 400,
so a failure names the case. The details:
- The body is the raw JSON from the file.
body.toString()sends the row as generated, missing key and impossible date included. - Two kinds of 400 are covered. A failed
@Validcheck (bad email, missing name, limit over 50,000) and a body Jackson can't read (February 30th) both get 400 from Spring MVC's default exception handling. If your@ControllerAdvicemaps them to 422, change the template and the expected statuses move with the data. - Valid rows prove the binding. For a 201 row,
verifychecks that the service got aCustomerRequestequal to the one the test's own mapper reads from the same JSON. Records compare by value, so a field the app's mapper binds differently fails here. - The mock needs no stubbing. An unstubbed
createreturns0, enough for theLocationheader.@MockitoBeanreplaces the deprecated@MockBeanfrom Spring Boot 3.4 on, and mocks are reset after each parameterized invocation.
@SpringBootTest: every row through the full stack
The duplicate-email row needs a real service and database behind the
controller. @SpringBootTest starts the whole application,
@AutoConfigureMockMvc adds MockMvc, and @AutoConfigureTestDatabase
keeps it on the embedded database even if application.properties
points at a real one. The test posts all 16 rows in file order:
// src/test/java/com/example/customers/CustomerSignupFlowTest.java
// Imports as in the two tests above, plus AfterEach, ArrayList,
// SpringBootTest, AutoConfigureMockMvc and AutoConfigureTestDatabase.
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureTestDatabase
class CustomerSignupFlowTest {
@Autowired MockMvc mvc;
@Autowired CustomerRepository customers;
@AfterEach
void deleteCustomers() {
customers.deleteAll();
}
@Test
void everyRowGetsItsExpectedStatusInFileOrder() throws Exception {
List<String> expected = new ArrayList<>();
List<String> actual = new ArrayList<>();
for (CustomerFixtures.Row row : CustomerFixtures.rows()) {
int status = mvc.perform(post("/api/customers")
.contentType(MediaType.APPLICATION_JSON)
.content(row.body().toString()))
.andReturn().getResponse().getStatus();
expected.add(row.scenario() + " -> " + row.expectedStatus());
actual.add(row.scenario() + " -> " + status);
}
assertThat(actual)
.as("fixture seed %d", CustomerFixtures.seed())
.isEqualTo(expected);
assertThat(customers.count()).isEqualTo(CustomerFixtures.valid().size());
}
}
This is one test rather than a parameterized one because order matters:
row 7 can only get its 409 after row 1 is stored. Comparing the two
scenario -> status lists at the end shows every row that went wrong
at once, and the final count proves the rejected rows weren't stored.
The test isn't @Transactional, on purpose. Each request commits as it
would in production, so the unique constraint and the service's
transaction are exercised for real, and @AfterEach deletes the rows.
A @Transactional @SpringBootTest would roll back MockMvc requests,
which run on the test thread, but not requests to a RANDOM_PORT or
DEFINED_PORT server, which run on server threads.
If signup calls another HTTP API, stub it at the HTTP layer; WireMock test data covers generating the stub bodies.
Reproduce a failure from the logged seed
With a committed file, most failures reproduce without a seed: check
out the commit and the test reads the same bytes CI read. If a
regeneration with a new seed breaks a test, the old seed is in the old
customers.json in Git, and scripts/regen-fixtures.sh 20261019
brings that file back to diff against. An optional scheduled job can
run scripts/regen-fixtures.sh random before ./mvnw test to try
other draws of names, limits and dates. That job is the one place an
API key and a metered call are needed. The script prints the seed the
API picked, and the .as("fixture seed %d", ...) descriptions put it
in the assertion message, so a red run replays locally (3211692223
is just an example):
scripts/regen-fixtures.sh 3211692223 && ./mvnw test
The planted rows and the per-tier rows sit at fixed indices, so at 10 or more rows every seed contains all six scenarios and an active customer in each tier; only the random values change. Seeds, logging and replay in general are covered in deterministic, reproducible test data.
When not to use generated Spring Boot test data
Generated fixtures add a template, a script and a file to review. Skip them for:
- Single-object unit tests. A service test that needs one
Customerwith a mocked repository is clearer with a builder call. For "some valid object", Instancio fills every field in one call (Instancio.create(CustomerRequest.class)) and Datafaker gives you a plausible name or email, both in process, seedable, and without a regen step. - A few static rows that never change. Reference data such as
three subscription plans is simpler as an
@Sqlscript ordata.sql, or as a migration if production needs it too. - No shared fixture need. If each test slice wants different data, one shared file buys you nothing.
Generated JSON fixtures pay off when one realistic, varied dataset is
shared by @DataJpaTest, @WebMvcTest and @SpringBootTest, and when
edge cases should be planted once, visible in a diff, with their
expected outcome next to them.
FAQ
How do I load JSON test data in a Spring Boot test?
Put the file under src/test/resources so it lands on the test
classpath, open it with getResourceAsStream, and parse it with a
Jackson ObjectMapper into Java records or DTOs. With Jackson 2, a
mapper you create yourself needs the JavaTimeModule registered before
it can read LocalDate or Instant fields. Load the file once in a
small helper class so every test slice reads the same data.
Does @DataJpaTest roll back after each test?
Yes. By default @DataJpaTest tests are transactional and roll back at
the end of each test, including rows inserted in a @BeforeEach
method, which runs inside the same test transaction. Use @Commit or
@Rollback(false) to keep the data. Because nothing is committed, call
flush() on the TestEntityManager after inserting so constraint
violations surface during the test.
Should I use @Sql scripts or JSON fixtures for Spring Boot tests?
@Sql and data.sql suit a few static rows that rarely change, such as
reference data. JSON fixtures suit data that several test slices share:
the same file can be persisted in @DataJpaTest and posted as request
bodies in @WebMvcTest, which a SQL script can't do. JSON fixtures also
load through your DTO classes, and with FAIL_ON_UNKNOWN_PROPERTIES enabled,
a field the DTO no longer has fails the load by name.
How do I test validation errors with MockMvc?
Post an invalid request body with MockMvc and assert the status,
usually 400 for a failed @Valid check or for JSON that can't be
parsed. To cover many cases, keep the bodies and their expected
statuses in a JSON file and drive one JUnit 5 @ParameterizedTest from
it with @MethodSource. Send the raw JSON rather than a DTO, because
some invalid inputs, such as an impossible date, can't be represented
by the DTO at all.
Why does ObjectMapper fail on LocalDate in my tests?
A plain new ObjectMapper() in Jackson 2 doesn't know java.time types
and fails with an error saying Java 8 date/time types are not supported
by default. Spring Boot's auto-configured mapper registers the
JavaTimeModule for you, but a mapper you build in a test or a static
helper doesn't. Register it with
JsonMapper.builder().addModule(new JavaTimeModule()).build().
Is there a JsonFabrica Spring Boot starter or Java SDK?
No. JsonFabrica is a JSON-over-HTTP API, and there is no Java SDK,
Spring Boot starter or JUnit extension. You call the API from a script,
for example with curl and jq, write the response to a file under
src/test/resources and commit it. The Spring project gets no new
dependency, and the tests make no network calls.
The fixture set in this post comes from a single call to JsonFabrica's template generate endpoint: one template, a fixed seed, and planted rows at known positions, committed as plain JSON your Spring tests read. The request and response format is in the templates API reference.
Generate realistic test data with JsonFabrica
Describe the shape of your data once, then generate as many fresh, realistic JSON documents as you need via a simple API call.