Stop Copying Prod Into Dev: Test Data Strategies That Actually Scale
Or: Your Database Snapshots Are a Confession, Not a Solution
It was April in Phaya Thai, and the aircon had died again. Three pedestal fans oscillated uselessly across the open-plan floor, pushing warm air from one side of the room to the other like a bureaucracy redistributing blame. I was staring at a progress bar — a database restore from production, creeping toward completion at the pace of a man who knows he’s early for a meeting he doesn’t want to attend. Forty minutes in, maybe twenty to go. The AC technician had been called. The DB restore had been started. Neither was going to finish fast enough to matter. Somewhere behind me, someone racked up the pool table, because what else do you do when your entire development environment is held hostage by a backup file that’s grown three gigabytes since anyone last checked?
That restore was the whole workflow. Not a workaround, not a temporary measure — it was how we got data into dev. Copy prod, strip out the bits that looked obviously sensitive, cross your fingers, and wait. And when the question inevitably surfaced years later in a different office, a different country, a different Slack channel — “Is there any way we can periodically take prod data snapshots and use them to seed our test environments?” — I recognised it instantly. Not as a bad question. As a confession. The same confession I’d been making back in that sweltering Phaya Thai office: we’ve lost the ability to describe our own data in code. And once you’ve lost that, everything downstream — your tests, your onboarding, your confidence in deployments — starts to rot from the inside.
As the physicist Richard Feynman once observed, “The first principle is that you must not fool yourself — and you are the easiest person to fool.” We’ve been fooling ourselves into thinking that production snapshots are a reasonable default. They’re not. They’re a crutch. And sometimes you genuinely need a crutch — but if you’re building a new system and reaching for one on day one, something’s already broken.
This post is more technical than my usual fare. There’s code. Quite a lot of it, actually. We’ll walk through working examples in both .NET and Spring Boot, covering how to eliminate lookup tables, seed data programmatically, build fluent test data factories, and run real databases in tests without shared state. If you’ve ever spent twenty minutes waiting for a Docker container stuffed with a QA database dump to finish starting up so you could run your tests, this one’s for you.
Why Prod Snapshots Hurt More Than They Help
Let’s enumerate the damage before we talk about alternatives, because the costs are easy to wave away when the snapshot is already sitting right there in your pipeline:
PII risk. Prod databases contain real customer data. Copying it to dev environments — even “anonymised” — creates compliance surface area that somebody has to manage. Anonymisation is harder than it sounds, and “good enough” anonymisation tends to decay over time as schemas change and new PII fields slip through the cracks.
Scale mismatch. Prod databases are enormous. Dev environments are not. You either copy a subset (which might miss the exact data shape you need) or the full thing (which takes hours and costs money nobody budgeted for).
Stale data. Snapshot from Tuesday, bug found on Friday. Is the snapshot still valid? Nobody knows. Nobody checks. Everybody assumes.
Shared-state fragility. This one deserves a story.
We had teams at Agoda creating test hotels in the shared QA database, writing assertions against those specific hotels, then watching their tests start failing across teams because someone else was exploring the same QA environment and changed something about that hotel. The tests weren’t testing code — they were testing a specific data state that anyone could mutate at any time. It’s the software equivalent of writing your exam answers on a shared whiteboard and being surprised when someone erases them.
We also had systems using database restores from QA — Docker containers with the data baked in. They were growing to multiple gigabytes, taking minutes just to start up. When we moved to SQL scripts with Testcontainers, the entire test suite started feeling like unit tests — that’s how fast it got.
We measured the impact concretely. We tracked how many test suites engineers actually ran locally on their dev machines versus what we saw running in CI, grouped at the MR level. The gap represented tests that were too slow or too painful to run locally — the tests engineers skipped because they didn’t want to wait.
The starting point was Docker Compose with data restored from QA baked into the containers. Running tests meant dropping to a CLI outside the IDE, waiting for multi-gigabyte containers to spin up, and hoping nobody had mutated the QA data since the last image build. Around 10–20% of MRs showed local test runs. The rest went straight to CI and waited.
We moved two things in one step: replaced the QA data restores with SQL scripts, and switched to Testcontainers. That combination made the integration tests fast and self-contained enough that we could add them to the default “Run All Tests” action in the IDE — something that wasn’t feasible with the old Docker Compose setup. Within a few weeks, local test execution jumped to 70% of MRs on some days running integration tests locally. Not because we told engineers to run their tests. Because we made it easy enough that they stopped skipping them.

The Principle: Your API Owns Its Data
Before we dive into techniques, let’s establish the principle that makes all of this work: if your API owns its data, it should be able to describe that data completely in code.
This connects directly to Domain-Driven Design and the bounded context concept — and if you think that’s ivory-tower talk, Bezos issued the same mandate at Amazon back in 2002. Your service’s database is an implementation detail of your API. If another team needs your data, they get it through your API, not by reading your tables. And if your API can serve the data, your code already knows what valid data looks like.
This means reference data — statuses, types, categories — should be defined in code, not manually inserted into lookup tables. Schema should be version-controlled through migrations, not applied by hand. Seed data for dev and test should be generated by the same code that validates it in production.
When this principle holds, you never need a prod snapshot for development. Your code is the authoritative description of your data.
As the management theorist W. Edwards Deming put it, “If you can’t describe what you are doing as a process, you don’t know what you’re doing.” If your API can’t describe its own data, what does that tell you?
Technique 1: Kill the Lookup Table (Or At Least Own It)
Lookup tables are one of the primary reasons teams reach for prod snapshots. “We need the StatusTypes table populated with the right IDs.” It’s the siren song of copying prod, and it starts with this one innocent dependency.
Here’s the progression of maturity:
Level 0: Manual Lookup Tables (The Problem)
Someone ran an INSERT script against prod years ago. Nobody’s sure if the dev database has the same values. The IDs might be different between environments. There’s no version control. This is where the snapshot reflex is born.
Level 1: Enum-Backed Lookup Tables with Auto-Seeding
Your code defines the enum. Your migrations create and populate the table. The database is always in sync with the code.
EF Core (.NET):
// Define the enum as the source of truth
public enum BookingStatus
{
Pending = 1,
Confirmed = 2,
Cancelled = 3,
Completed = 4,
Refunded = 5
}
// Create a lookup entity that mirrors the enum
public class BookingStatusLookup
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
// In your DbContext OnModelCreating
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Seed the lookup table from the enum - always in sync
modelBuilder.Entity<BookingStatusLookup>().HasData(
Enum.GetValues<BookingStatus>()
.Select(e => new BookingStatusLookup
{
Id = (int)e,
Name = e.ToString()
})
.ToArray()
);
// Your booking entity uses the enum directly
modelBuilder.Entity<Booking>()
.Property(b => b.Status)
.HasConversion<int>(); // Stored as int, used as enum
}
Spring Boot / JPA:
public enum BookingStatus {
PENDING, CONFIRMED, CANCELLED, COMPLETED, REFUNDED
}
@Entity
public class Booking {
@Id @GeneratedValue
private Long id;
@Enumerated(EnumType.STRING)
private BookingStatus status;
}
With Flyway or Liquibase migrations, the schema and constraints are always derived from your code and version-controlled. You’ll see spring.jpa.hibernate.ddl-auto=create in tutorials — it's fine for local prototyping, but in real services it produces "works on my machine" schemas that drift between environments and miss indices or constraints that migrations would express. For anything beyond a throwaway spike, let your migration tool own the schema.
A word on enum stability.* The moment you seed a database from an enum, the numeric values become data. Treat them like a public API. Always assign explicit values (**Pending = 1, not just **Pending). Never reorder enum members — if **Cancelled = 3 has been written to 50,000 rows, reordering it doesn't update those rows. Never delete enum values — mark them obsolete but keep the numeric value reserved. EF Core's **HasData will generate a DELETE migration if the value disappears, which will FK-violate against existing rows. In Spring/JPA, prefer **EnumType.STRING over *EnumType.ORDINAL for exactly this reason — ordinal values break when enums are reordered, while string values are stable.
Level 2: Smart Enums — No Lookup Table At All
If your API owns its data and no other service reads your database directly, you may not need a lookup table at all. The enum is the reference data.
Ardalis SmartEnum (.NET):
public sealed class BookingStatus : SmartEnum<BookingStatus>
{
public static readonly BookingStatus Pending = new(nameof(Pending), 1);
public static readonly BookingStatus Confirmed = new(nameof(Confirmed), 2);
public static readonly BookingStatus Cancelled = new(nameof(Cancelled), 3);
public static readonly BookingStatus Completed = new(nameof(Completed), 4);
// You can add behaviour directly on the enum
public bool CanTransitionTo(BookingStatus target) =>
(this, target) switch
{
(_, _) when this == target => false,
var (from, to) when from == Pending
=> to == Confirmed || to == Cancelled,
var (from, to) when from == Confirmed
=> to == Completed || to == Cancelled,
_ => false
};
private BookingStatus(string name, int value) : base(name, value) { }
}
// EF Core value conversion — stores as int, no lookup table needed
modelBuilder.Entity<Booking>()
.Property(b => b.Status)
.HasConversion(
s => s.Value,
v => BookingStatus.FromValue(v)
);
The API returns "Confirmed" to the client. The database stores 2. The code defines both. No lookup table, no seeding, no prod snapshot needed.
The DBA Pushback (And Why It’s Half Right)
The line “lookup tables exist because databases predate APIs” is deliberately provocative. It needs to be, because the default assumption in most organisations is that lookup tables are obviously correct, and that assumption goes unexamined. But DBAs will push back, and some of their arguments are legitimate.
“What about referential integrity?” This is the strongest argument — but only if the database has multiple writers. In a microservices architecture where your API is the only writer to its database, you already control all writes. The enum in your code is the constraint — enforced at compile time, which is strictly stronger than a runtime FK violation. You can’t even express an invalid value in a language with a proper type system.
If you want both, use Level 1: define the enum in code, auto-seed the lookup table from the enum via migrations, and let the FK exist as a belt-and-suspenders safety net. The key is that the enum is the source of truth, and the table is derived from it. Not the other way around.
“What about reporting?” When an analyst runs SELECT status_id, COUNT(*) FROM bookings GROUP BY status_id, they see numbers. Fair. Three responses: store as string instead of int (EF Core's EnumToStringConverter and JPA's @Enumerated(EnumType.STRING) handle this trivially); auto-seed the lookup from the enum as a view rather than independent data; or — the microservices answer — reporting should hit a read model, not your service database. If reporting queries hit your transactional database directly, you have the "Reach-in Reporting" antipattern that Mark Richards describes in Microservices AntiPatterns and Pitfalls.
“So when do lookup tables win?” When the values are user-editable at runtime — product categories a business user can add without a code deploy. When the values carry metadata beyond name and ID — a Currency table with symbol, decimal places, active flag. When multiple applications write to the same database. When your reporting layer needs to join against them in a read model.
The argument isn’t “never use lookup tables.” It’s “stop treating them as the default, and start asking who consumes this database.”
Technique 2: Programmatic Data Seeding
The Maturity Ladder
Level Practice Reproducible? PII Risk 0 Shared prod clone No — point in time High 1 SQL scripts in repo Partially — may not be idempotent None 2 Migration-based seeds (HasData, Flyway) Yes — deterministic None 3 Code-driven seeding + fluent builders Yes — type-safe None 4 Ephemeral DB per test suite (Testcontainers) Yes — clean slate None
Most teams are at Level 0 or 1 and think they need to jump to Level 4. They don’t — each level is an improvement. Moving from Level 0 to Level 2 alone eliminates PII risk and makes environments reproducible.
EF Core HasData (for migration-managed seed data):
// In OnModelCreating — generates INSERT statements in migrations
modelBuilder.Entity<Currency>().HasData(
new Currency { Id = 1, Code = "USD", Name = "US Dollar", Symbol = "$" },
new Currency { Id = 2, Code = "THB", Name = "Thai Baht", Symbol = "฿" },
new Currency { Id = 3, Code = "EUR", Name = "Euro", Symbol = "€" }
);
HasData requires explicit primary keys and is designed for data that changes rarely. It generates migration scripts, so it's version-controlled and reproducible.
IHostedService seeding (EF Core 6/7/8 — the pattern most teams actually use):
public class DatabaseSeeder : IHostedService
{
private readonly IServiceProvider _serviceProvider;
public DatabaseSeeder(IServiceProvider serviceProvider)
=> _serviceProvider = serviceProvider;
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider
.GetRequiredService<BookingDbContext>();
await context.Database.MigrateAsync(cancellationToken);
if (!await context.Currencies.AnyAsync(cancellationToken))
{
context.Currencies.AddRange(
new Currency { Code = "USD", Name = "US Dollar", Symbol = "$" },
new Currency { Code = "THB", Name = "Thai Baht", Symbol = "฿" },
new Currency { Code = "EUR", Name = "Euro", Symbol = "€" }
);
await context.SaveChangesAsync(cancellationToken);
}
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
// In Program.cs
builder.Services.AddHostedService<DatabaseSeeder>();
This runs on every application startup, applies pending migrations, and seeds data idempotently. It’s the pragmatic choice for teams not yet on EF Core 9.
A word of caution: register this seeder in development and test environments only. If someone cargo-cults it into production without an environment guard, you’ve introduced implicit startup-time database mutation — and if two pods start simultaneously, you’ve got a race condition. In production, prefer migration tooling that runs outside the application process. Your paved path templates should make the safe thing the default: seeder registered in Development, migrations applied via a pipeline step.
Spring Boot — Flyway migrations with seed data:
-- V1__create_currency_table.sql
CREATE TABLE currency (
id SERIAL PRIMARY KEY,
code VARCHAR(3) NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL,
symbol VARCHAR(5) NOT NULL
);
-- V2__seed_currencies.sql
INSERT INTO currency (code, name, symbol) VALUES
('USD', 'US Dollar', '$'),
('THB', 'Thai Baht', '฿'),
('EUR', 'Euro', '€');
Flyway tracks which migrations have run. Add a new currency? Add a new migration. Version-controlled, reproducible, works identically in every environment.
Technique 3: Fluent Test Data Builders
This is where we get to the heart of the prod-snapshot reflex. The reason engineers reach for snapshots in tests is because constructing valid domain objects is painful. A Booking might need a Property, a Guest, a RatePlan, a PaymentMethod, and three Currency records before it’s valid. When the alternative is a single docker-compose up that gives you everything, of course people take the shortcut.
The answer: builder patterns that make constructing complex test data trivial.
The .NET builder pattern:
public class BookingBuilder
{
private Guid _id = new("00000000-0000-0000-0000-000000000001");
private string _guestName = "Test Guest";
private DateTime _checkIn = new(2025, 6, 1);
private BookingStatus _status = BookingStatus.Pending;
private string _currency = "USD";
private int _nights = 3;
private decimal _amount = 450.00m;
private int _propertyId = 1;
public BookingBuilder WithId(Guid id)
{ _id = id; return this; }
public BookingBuilder WithGuest(string name)
{ _guestName = name; return this; }
public BookingBuilder CheckingInOn(DateTime date)
{ _checkIn = date; return this; }
public BookingBuilder WithStatus(BookingStatus status)
{ _status = status; return this; }
public BookingBuilder InCurrency(string currency)
{ _currency = currency; return this; }
public BookingBuilder ForNights(int nights)
{ _nights = nights; return this; }
public BookingBuilder WithAmount(decimal amount)
{ _amount = amount; return this; }
public BookingBuilder AtProperty(int propertyId)
{ _propertyId = propertyId; return this; }
public Booking Build() => new()
{
Id = _id,
GuestName = _guestName,
CheckIn = _checkIn,
CheckOut = _checkIn.AddDays(_nights),
Status = _status,
TotalAmount = _amount,
Currency = _currency,
PropertyId = _propertyId
};
public static BookingBuilder ABooking() => new();
}
// Usage in tests — reads like English, every value is predictable
var booking = BookingBuilder.ABooking()
.WithStatus(BookingStatus.Confirmed)
.InCurrency("THB")
.ForNights(7)
.WithAmount(2800.00m)
.Build();
// Need a cancelled booking? Override only what matters
var cancelled = BookingBuilder.ABooking()
.WithStatus(BookingStatus.Cancelled)
.Build();
// Everything else — guest name, check-in date, amount —
// uses sensible defaults. Deterministic. Every time.
Notice: no Faker, no Random, no Guid.NewGuid(). Every default is an explicit, known value. When a test creates a booking without specifying an amount, it gets 450.00 — not a random number between 50 and 5,000 that might trigger a different code path on Tuesday than it did on Monday.
This is a deliberate choice. Random test data is the enemy of deterministic tests. If your test passes with a random amount of 150.00 but fails with 5,001.00 because it hits a threshold you forgot about, you’ve got a flaky test that will erode trust in your entire suite. Tests should be boring. Tests should be predictable. The only values that vary should be the ones the test is explicitly exercising.
Spring Boot / Java equivalent:
public class BookingBuilder {
private String guestName = "Test Guest";
private LocalDate checkIn = LocalDate.of(2025, 6, 1);
private int nights = 3;
private BookingStatus status = BookingStatus.CONFIRMED;
private BigDecimal totalAmount = new BigDecimal("450.00");
private String currency = "THB";
public static BookingBuilder aBooking() {
return new BookingBuilder();
}
public BookingBuilder withGuest(String name)
{ this.guestName = name; return this; }
public BookingBuilder checkingInOn(LocalDate date)
{ this.checkIn = date; return this; }
public BookingBuilder withStatus(BookingStatus status)
{ this.status = status; return this; }
public BookingBuilder withAmount(BigDecimal amount)
{ this.totalAmount = amount; return this; }
public BookingBuilder inCurrency(String currency)
{ this.currency = currency; return this; }
public BookingBuilder forNights(int nights)
{ this.nights = nights; return this; }
public Booking build() {
return Booking.builder()
.guestName(guestName)
.checkIn(checkIn)
.checkOut(checkIn.plusDays(nights))
.status(status)
.totalAmount(totalAmount)
.currency(currency)
.build();
}
}
// Same pattern, same determinism
var booking = BookingBuilder.aBooking()
.withStatus(BookingStatus.PENDING)
.inCurrency("USD")
.forNights(5)
.withAmount(new BigDecimal("750.00"))
.build();
The key message here is uncomfortable but important: if creating valid test data is harder than copying prod, the problem is your test data tooling, not the absence of prod data.
Why Not Bogus/Faker?
Libraries like Bogus and Java Faker are popular, and you’ll see them recommended everywhere. They generate realistic-looking random data — names, addresses, amounts, dates. They’re fun to use. They also introduce non-determinism into your tests, which is another way of saying they introduce flakiness that you can’t reproduce.
A test that fails in CI but passes locally because Faker generated a different value is worse than a test that doesn't exist. At least the missing test isn't lying to you. Random data means your tests exercise different code paths on different runs. That's not testing — that's hoping.
If you need to test multiple data shapes, write multiple test cases with explicit values. It’s more code, yes. It’s also code that fails the same way every time, which means you can actually debug it.
Technique 4: Testcontainers — Real Databases, Zero Shared State
This is where everything comes together. Testcontainers gives you ephemeral, real database instances per test suite — your tests run against the same database engine as production, with data you control completely.
The .NET pattern (WebApplicationFactory + Testcontainers):
public class BookingApiFactory
: WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres =
new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// Remove the real database registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType ==
typeof(DbContextOptions<BookingDbContext>));
if (descriptor != null) services.Remove(descriptor);
// Replace with Testcontainers connection
services.AddDbContext<BookingDbContext>(options =>
options.UseNpgsql(
_postgres.GetConnectionString()));
});
}
public async Task InitializeAsync()
{
await _postgres.StartAsync();
// Apply migrations — your schema is always current
using var scope = Services.CreateScope();
var context = scope.ServiceProvider
.GetRequiredService<BookingDbContext>();
await context.Database.MigrateAsync();
}
public new async Task DisposeAsync()
=> await _postgres.DisposeAsync();
}
The test class — clean, focused, no shared state:
public class BookingApiTests
: IClassFixture<BookingApiFactory>
{
private readonly HttpClient _client;
private readonly BookingDbContext _db;
public BookingApiTests(BookingApiFactory factory)
{
_client = factory.CreateClient();
var scope = factory.Services.CreateScope();
_db = scope.ServiceProvider
.GetRequiredService<BookingDbContext>();
}
[Fact]
public async Task CreateBooking_ReturnsCreated()
{
// Arrange — use your builders, not prod data
var request = BookingBuilder.ABooking()
.WithStatus(BookingStatus.Pending)
.InCurrency("USD")
.Build();
// Act
var response = await _client
.PostAsJsonAsync("/api/bookings", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var created = await response.Content
.ReadFromJsonAsync<Booking>();
created.Status.Should().Be(BookingStatus.Pending);
}
}
For Spring Boot, the pattern is nearly identical: @Testcontainers annotation, @Container for the PostgreSQL container, @DynamicPropertySource to wire the connection string, and @SpringBootTest with RANDOM_PORT. The key insight is the same: your test owns its database, your migrations run on startup, your builders provide the data.
Why this beats a prod snapshot or shared QA database:
Each test suite gets a fresh database — no “someone changed my test hotel” problems. Migrations are tested every time — schema drift is caught immediately. Tests run in CI with no external dependencies beyond Docker. Data is deterministic. No PII in dev environments.
We run Testcontainers in Docker-in-Docker in our CI; our CI network is 20Gbps so the image pull is fast, though the Docker layer cache not caching is a known annoyance we’re actively working on. Real-world friction exists. But the alternative — maintaining multi-gigabyte snapshot containers — creates more friction, not less.
Two gotchas worth mentioning before you hit them in production CI: first, parallel test execution can spin up too many containers and exhaust ports or memory — start one container per test suite, not per test. Second, if your CI runners don’t support privileged Docker, Testcontainers will fail entirely. In that case, fall back to a shared ephemeral database per pipeline stage, provisioned and torn down by the pipeline itself. It’s not as clean as per-suite isolation, but it’s still miles ahead of a shared QA database that anyone can mutate.
When a Snapshot Is Actually the Right Tool
Before we get to the decision framework, let’s be clear: this post is not anti-snapshot. Snapshots are a tool, and like any tool, the problem isn’t the tool — it’s reaching for it by default instead of by design.
The Decision Framework
When you’re staring at a new test file and wondering how to get data into it, here’s the decision tree:
Is this a new system you’re building? Seed from code. Period. Define your reference data in enums, use HasData or Flyway for seed data, build test data factories, run Testcontainers for integration tests. You have no excuse and no legacy to blame.
Is this an existing system with code-first schema management? You should still seed programmatically. If your seeding is missing data, fix the seeding — that’s a bug, not a reason to clone prod.
Is this a legacy system where you don’t fully control the schema? Years ago, I was at a company where we took over systems from another outfit. 248 tables, and we only had more features to add. Getting the schema into source control was the first barrier — at least we could control versioning. But getting all the data we needed for test cases in the right structure with the right relations? That’s what we’d lost control of. The quickest solution: take a backup, anonymise the PII, and there’s your test database. It only works at small scale, too — you can’t be doing that with a terabyte database.
Do you need realistic data distributions for performance testing? Prod snapshot — anonymised — is the right tool. Synthetic data can’t replicate production data distributions for query optimisation. We still use snapshots for testing index changes at Agoda, and that’s the right call.
Do you need to reproduce a specific production bug? Targeted snapshot of the relevant data. Reproduce. Fix. Delete the snapshot.
Even in the cases where snapshots are justified, use guardrails: anonymise PII before it leaves production, time-box the snapshot so it doesn’t become the permanent dev database, and document why you needed it. If the answer is “we can’t create valid test data,” that’s a problem to fix, not a state to accept.
The Bottom Line
The Slack message that started this post wasn’t a bad question. It was a perfectly rational question from someone dealing with real friction. The problem isn’t the question — it’s that our industry has normalised the answer.
The coach John Wooden once said, “If you don’t have time to do it right, when will you have time to do it over?” Every prod snapshot you take is a bet that you’ll never need to do this properly. And every month that passes, the bet gets more expensive. The snapshot grows. The PII surface area expands. The schema drifts further from the code. The tests get slower. The engineers stop running them.
The path out isn’t dramatic. It’s incremental. Move from Level 0 to Level 2 on the maturity ladder. Own your enums. Seed your reference data from code. Build one deterministic test data builder for your most common domain object. Set up one Testcontainers integration test. Measure how many tests your engineers actually run locally.
That 20% to 70% jump in local test execution we saw? It didn’t come from a heroic rewrite. It came from making the default workflow fast enough that people stopped skipping it. That’s the whole game. Make the right thing the easy thing, and the right thing happens.
Now, if you’ll excuse me, I need to go review a merge request that adds a new enum value to our booking status. The engineer helpfully put it in the middle of the list without an explicit integer value. At least our migration tests will catch it — because we actually run those now.