8 types of automated testing for your PHP app
@tomnewbyau
tomnewby.net
About Hyra iQ
- A digital contracting platform for retail and commercial leasing
- Greenfields build ~12 months ago
- Long-term maintainability
- Small team with lofty goals
- Experienced with Symfony, but not so much testing
- "Does it work?" - "Will it work tomorrow?"
Outline
- First line of defence: types
- From PhpUnit to browser-based tests
- Contract testing
- Mutation testing
- Domain-specific tests
Types
- Type your code as much as possible
- Requires some changes to the way you write code
- Use associative arrays sparingly
- Use static analysers like vimeo/psalm or PhpStan
- declare(strict_types=1);
- Faster than running a whole test suite
- Easier refactoring/better feedback in IDEs
Unit tests
Comprehensive tests of the logic in a single unit
- Probably PhpUnit
- Low memory consumption
- Pure PHP, no external deps (cache/db)
- Safe to parallelize: paratestphp/paratest
- Take this to the next level with Infection: infection/infection
- Ideally as minimal mocking as possible
Example unit
<?php
declare(strict_types=1);
// ... imports go here
final class WorkspacePermissions
{
private function __construct()
{
}
public static function canIViewThis(Workspace $workspace, User $user): bool
{
foreach ($workspace->getOrganisations() as $organisation) {
if (OrganisationPermissions::canISeeThis($organisation, $user)) {
return true;
}
}
return false;
}
}
Example unit test
<?php
declare(strict_types=1);
// ... imports go here
class WorkspacePermissionsTest extends TestCase
{
public function testUserCanViewWhenOrgInWorkspace(): void
{
$user = (new UserBuilder())->withOrganisation()->create();
$workspace = (new WorkspaceBuilder())->withOwner($user->getOrganisation())->create();
static::assertTrue(WorkspacePermissions::canIViewThis($workspace, $user));
}
public function testUserCannotViewWhenOrgNotInWorkspace(): void
{
$user = (new UserBuilder())->withOrganisation()->create();
$workspace = (new WorkspaceBuilder())->create();
static::assertFalse(WorkspacePermissions::canIViewThis($workspace, $user));
}
}
Unit tests
Good for:
- Testing specific logic: can a user see a workspace?
Not great for:
- General system correctness:
- can a user view the workspace page
- can a user see the workspace in their workspace list
Unit tests
Functional core, imperative shell
Test the core with unit tests, builders over mocks
Lots of @dataProvider based tests
Reference stats:
- 544 tests
- Local, xdebug off: 2.5s, 52MB
X unit tests, 0 integration tests
Integration tests
Combine multiple units and test them as a group
- Terminology gets fuzzy and varied here
- Still in PHP + PhpUnit, but more memory intensive
- Boot the framework
- Interacting with services in the DI container
- Possibly external dependencies, but usually alternatives
- Database (sqlite or in-memory)
- Cache (in-memory, disk)
- Maybe database fixtures
- Depending on external dependencies, might not parallelise easy
Integration Test Example
Test subject: CancelInviteService
It should: mark the invite as cancelled, notify the cancelled person and the other people working on the project via emails
<?php
class CancelInviteTest extends WebTestCase
{
function testItCancelsAndNotifies(): void
{
// ... arrange the fixture $invite
static::bootKernel();
/** @var CancelInviteService $service */
$service = static::$container->get(CancelInviteService::class);
// Act
$service->cancel($invite);
// Assert
static::assertTrue($invite->isCancelled());
/** @var StubMailer $stubMailer */
$stubMailer = static::$container->get(StubMailer::class);
static::assertTrue($stubMailer->hasOneMessageForEachRecipient(...$recipients));
}
}
Integration tests
Good for:
Units interacting:
- When I cancel an invite, emails get sent (how? idc)
General system correctness
- Cancelling an invite, notifies the cancelled person and the other people working on the project via email
Testing things that interact with DI + Event
Not great for:
Specific logic, edge cases:
- "Cancelled users with no projects or invites should be marked as inactive unless they're from an organisation with SSO"
Reference stats:
- 216 tests
- Local, xdebug off: 55s, 108MB
Functional tests
Testing web requests a real user would make, but not in a browser
- Aka HTTP tests in Laravel
- Still PhpUnit, so pure PHP
- Boot the framework
- Pass in a web request crafted in PHP
- No actual browser used
- Very similar to integration tests (external deps etc)
Functional test snippets
<?php
// Load a page, assert on the status code
$client = static::createClient();
$client->request('GET', '/');
static::assertSame(200, $client->getResponse()->getStatusCode());
// Make a DOM crawler to inspect the page
$crawler = $client->getCrawler();
$form = $crawler->selectButton('Login')->form();
$form['_username'] = $user->getEmail();
$form['_password'] = TestParameters::DEFAULT_PASSWORD;
// Submit forms and assert you get redirected
$client->submit($form);
static::assertTrue($client->getResponse()->isRedirect()));
Functional tests
Good for covering:
- Everything that integration tests covered
- Request parsing (json formatting)
- Request/response headers
- Firewall and security
- If a user actually does this thing, will it work
Tend to be long, slow, cumbersome to write
Functional tests
We rely on these the most! Most services not that complicated
Reference stats:
- 346 tests
- Locally, xdebug off: 7 minutes
- We split by namespace, but worst is 3min, 126MB
Unit, Integration and Functional illustrated
Unit, Integration and Functional illustrated
Browser-based testing
- Boot a real browser and click around
- Selenium, Chrome WebDriver, Puppeteer, Cypress.io
- PHP associated projects: Laravel Dusk, Symfony Panther
slow, fragile, but...
almost exactly what your user will see
Browser-based testing
- Real browser connecting to a real webserver
- Test runner probably not written in PHP, can't access the container or database
- Based on user acceptance scripts
- Can everyone run it locally?
- Where is the test code living?
Browser-based testing
- We don't have anything running regularly here
- Percy.io visual regression
- Well tested API + well tested frontend components = 80% of the way
- Questions we want answered with tests here
- Does the frontend boot without errors?
- Do common, broad user flows still work? Do they look different?
Contract Testing
- I want to guarantee my API meets a contract, that the frontend was developed against
- I.E. if I don't serialise the correct properties to send to the frontend, what test will break?
- json-schema.org
- DIY: PhpUnit + Symfony WebTestCase + justinrainbow/json-schema
- Test process:
- Load db fixtures
- Hit endpoint
- Validate JSON against stored schema
JSON Schema Example
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"required": ["deals", "totals"],
"properties": {
"deals": {
"type": "array",
"items": {
"$ref": "./deal.json"
}
},
"totals": {
"required": ["all", "acting"],
"type": "object",
"properties": {
"all": {
"type": "integer"
},
"acting": {
"type": "integer"
}
}
}
}
}
{
"deals": [{
"id": "some-uuid",
"name": "Krusty Burger",
...
}],
"totals": {
"all": 32,
"acting": 7
}
}
Example JSON response
Example JSON schema
Contract Testing
- Applied here to API responses
- Great for catching regressions in data serialization
- Applicable in other circumstances
Mutation Testing
Demo Screenshots
Demo Screenshots
Mutation testing with infection
- Fast, but pretty slow still
- Too many false-positives in framework code (controllers)
- Security + functional core namespaces are mutated
Reference Stats:
- Mutating 544 unit tests/107 files
- Initial run takes 1m 44s with xdebug
- Produces 616 mutants
- Runs in 1min 39s
Domain-specific testing
Testability informs architecture
Message Tests
- Our app sends ~50 unique emails
- Content is really important
- Don't leak data
- Links need to work
- MessageInterface abstraction is easier to test
- Emerging design as we understand the problem better
Message Example
<?php
// This can be generated wherever and passed around
$messageObject = new RequestPasswordResetMessage($user, $token);
// But eventually it needs to be sent
$this->mailer->send($messageObject);
<?php
class RequestPasswordResetMessage extends AbstractPersonMessage
{
public function __construct(User $user, string $token)
{
$subject = \sprintf('%s, forgot your password?', $user->getFirstName());
parent::__construct($user, $subject);
$this->addContext([
'token' => $token,
]);
}
public function getHTMLTemplate(): string
{
return 'User/Message/request-password-reset.html.twig';
}
public function getPlainTemplate(): string
{
return 'User/Message/request-password-reset.txt.twig';
}
}
Message Example
<?php
class RequestPasswordResetMessageTest extends MessageTestCase
{
public function testMessage(): void
{
// Create the message
$message = new RequestPasswordResetMessage($user, $token);
$test = (new MessageTestBuilder($variation->getMessage()))
->toEmail($user->getEmail())
->subjectContains(\sprintf('%s, forgot your password?', $user->getFirstName()))
->salutation($user)
->bodyContains(
'Forgot your password?',
'Click the link below to reset your password'
)
->link('Reset your password', 'user.password_reset.claim', ['token' => $token])
;
$this->assertMessageTest($test);
}
}
Message Tests
- Extension of WebTestCase to add utility
- Builder to improve the developer experience
- Driven out of specificity to our domain
- How might this apply to your domain?
Reference Stats:
- 68 tests in 12 seconds
Any question about testing?
me@tomnewby.net
@tomnewbyau on Twitter
8 types of automated testing for your PHP app
By tomnewbyau
8 types of automated testing for your PHP app
- 825