Article

I built a fake delivery company to test the real one

I've always been a proponent for testing complex systems . I built ecoeats before AI really took off. Where nowadays an e2e test is simpler to write and maintain, back in the day it was simply too much work to build these and keep them in sync with the reality. It was easier to write simulators, adapters for each part of the order system that could execute events and allow the system to respond in real time to whatever you threw at it.


Simulations formed the backbone of my testing strategy. I could see, end to end in the system, what happened from basket construction to order placement to delivery, as well as inspect the system live on all of the apps that interfaced with the ecoeats system.


It might be worth thinking of this as 'chaos' testing - my simulated customers, riders and restaurants were unpredictable. They would cancel orders, amend them, fail to make deliveries correctly or on time, restaurants would fail to prepare the food anywhere close to the scheduled pickup time - and so on!



A fixture doesn't get hungry

A fixture can tell you whether an order with a particular status renders correctly. It can't, by itself, tell you what happens when a restaurant takes longer to prepare food while a rider is approaching and another order becomes available.


Those are interactions over time. The data has a history, and that history affects which actions should be possible next.


The simulator code covered things such as placing orders, accepting or rejecting work, moving towards destinations, and exercising collection and delivery actions. It also reached into reusable-container workflows. That breadth matters because a delivery business isn't one tidy state machine. It's several state machines trying to agree about lunch.

Make the actors use the application

There are two tempting ways to simulate an order being accepted.


The first is to update its status directly in the database. Very fast. Very easy. Unfortunately, you've just stepped around the code you were hoping to exercise.


The second is to ask the application to perform the acceptance action. That gives its normal rules a chance to reject the request, update related records, or produce whatever subsequent work should follow.


The rider simulation used application-level actions for important transitions. That's the principle worth keeping. It doesn't mean the entire simulator was a pure black-box test: it also used services and database access, and calling a resolver in-process doesn't test HTTP, authentication middleware, or mobile rendering.


I'd call it a behavioural integration simulator, not proof that the whole product works end to end.

A tiny version of the idea

Here is deliberately incomplete TypeScript-style pseudocode for one actor. The application owns the legal transitions; the actor chooses whether to attempt one:


async function stepRider(world, rider, random) {
const view = await world.readRiderView(rider.id);
if (view.offer) {
const action = random() < 0.8 ? 'accept' : 'reject';
return world.act(rider.id, action, view.offer.id);
}
if (view.destination) {
await world.moveTowards(rider.id, view.destination);
}
if (view.availableAction && world.hasArrived(rider.id)) {
await world.act(rider.id, view.availableAction);
}
}

The probability is invented for this example. In a useful simulator, behaviour belongs in explicit scenario settings, not unexplained magic numbers scattered through the code.


Start with one merchant, one rider, and one order. Make the happy path legible before adding twenty ways for it to become unhappy.

Randomness finds a problem. A seed lets you keep it

A busy simulation can make a dashboard look wonderfully alive while producing almost no actionable evidence. If a failure disappears on the next run, you now have a ghost story rather than a regression test.


My next iteration would make the clock injectable and the random source seedable. Record the seed, scenario settings, and actions attempted. Keep a compact trace of accepted transitions and rejected ones. That way, an interesting failure can become a deterministic test.


The historical implementation used ordinary randomness and scheduled work in places. Reproducibility is an improvement I'd make, not a property I'm claiming it already had everywhere.

Simulated doesn't automatically mean safe

A fake customer can still trigger a real email if you connect the wrong adapter.


Run a system like this against isolated data, sandbox payment services, and disabled or intercepted external messaging. Refuse production credentials at startup. Put a visible simulation marker on generated records and make cleanup bounded and repeatable.


An environment-name check is helpful, but it isn't the entire safety boundary. The safest simulator cannot reach real money or real people in the first place.

What to measure

I'd start with invariants: completed work should have the required preceding transitions; rejected offers shouldn't leave phantom assignments; a cancelled order shouldn't quietly continue through fulfilment.


Then look at liveness. Does work eventually move forward when capacity exists? Can a scenario get stuck without producing a visible error? Does a burst of activity expose something that the single-order path doesn't?


Don't claim performance improvements from a lively map. A realistic simulation and a calibrated load test are different instruments.

Build the smallest world that can disagree with you

There is a trap here: spending so long simulating the business that you accidentally build a second business.


The useful version is smaller. It exercises the troublesome transitions, preserves the interesting failures, and gives you enough control to repeat them.


A fixture says, “Imagine an order in this state.” A behavioural simulator asks, “How did it get there, and can it get out again?”


That's a much better question.

Made with Intenso