How we write REST APIs with TDD
Jak píšeme REST API pomocí TDD. Write tests. Lots of tests. Here’s how we got from zero tests to one for every endpoint of our API, and why we now write the test first.
Who tests?
I opened with three questions to the room: who writes tests, who writes REST APIs, and who tests their REST API. For a long time I wasn’t in that last group.
Zero tests
Four or five years before the talk I built Odbav.to. Two years of development, a one-man show, on Nette. The number of tests: zero. The tests directory in that repository contains a .htaccess and a web.config, and nothing else.
Entrydo was a different setup: a team with two backend developers, two web developers and one mobile developer, all depending on the same API. That doesn’t work without tests. Today there are tests everywhere: a test for every presenter in the API.
What we test
Our API endpoints are Nette presenters. Each one is thin: it takes the request, hands it to a use case, and returns a response.
final class BillingInfoDetailPresenter extends UserAwarePresenter implements IPresenter
{
/** @var BillingInfoDetailUseCase */
private $useCase;
public function __construct(BillingInfoDetailUseCase $useCase)
{
$this->useCase = $useCase;
}
public function run(Request $appRequest): IResponse
{
$response = $this->useCase->handle(
new BillingInfoDetailRequest($this->userId)
);
return new OKResponse($response);
}
}
So we test the whole thing from the outside: send an HTTP request, check the HTTP response.
How we used to test
The first version was one test class per presenter. Make a request, assert the status code:
class BillingInfoDetailPresenterTest extends TestCase
{
use DatabaseSetup;
/** @test */
public function responseShouldBeValid(): void
{
$httpClient = $this->createHttpClient();
$httpClient->request(IRequest::GET, RouterFactory::BILLING_ROUTE);
$response = $httpClient->getResponse();
$this->assertSame(IResponse::S200_OK, $response->getStatus());
}
}
It works, but you write the same class over and over, and a 200 status says very little about what came back.
How we test now
Now a single test goes through a data provider. Each case is a name, a URL, a JSON file with the request body, a JSON file with the expected response, and the values to put into the URL:
public function getPresenters(): array
{
return [
'Digital Queue - Pair ticket with phone' => [
'/digital-queues/tickets/<ticketId>/pair-with-phone',
__DIR__ . '/PostRequestData/digital-queue/pair-ticket-with-phone/request.json',
__DIR__ . '/PostRequestData/digital-queue/pair-ticket-with-phone/response.json',
['ticketId' => TicketFixture::getTicketId()],
],
'Digital Queue - Queue reservation - Reserve' => [
'/digital-queues/tickets/<ticketId>/queues/<queueId>/reservation',
__DIR__ . '/PostRequestData/digital-queue/queue-reservation/reserve/request.json',
__DIR__ . '/PostRequestData/digital-queue/queue-reservation/reserve/response.json',
[
'ticketId' => TicketFixture::getTicketId(),
'queueId' => EventQueueFixture::QUEUE_ID,
],
],
];
}
The test sends the request through an in-process HTTP client and compares the JSON it gets back with the expected file. Adding a test for a new endpoint means adding two JSON files and one array entry. The examples in the talk repository show the rest: the test client is built on NBrowserKit, the clock is mocked so responses with dates stay stable, and every run gets a fresh database with its schema created from the Doctrine entities and data loaded from fixtures.
The workflow
This is where the TDD comes in. For a new endpoint we go in this order:
- Expectations: a new entry in the data provider, with the JSON request, the JSON response and the URL.
- Presenter: write the code until the response matches.
- Router: wire up the route.
The challenges
- Starting to write tests at all
- Finding a way of testing that suits you
- Demo data
- Speed
- Automation
Why we test
- Debugging: a test is an easy way into any endpoint with Xdebug.
- Full automation: 6 minutes from push to production.
- We can sleep peacefully.
The API test suite alone was 243 tests with 480 assertions, running in 2.4 minutes in CI.
The rest of the talk was a live demo. It’s in the recording, and the example code is on GitHub.
All 26 slides
Related talks
- Fast database tests with PHPUnit, Nette and Doctrine · 2024
- Continuous Deployment is the best! · 2022
Video Example code on GitHub NBrowserKit Nettrine Fixtures All talks
























