Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Crafting Elegant Symfony Tests

Crafting Elegant Symfony Tests

Writing and reviewing integration and functional tests with Symfony can be clunky. Using the traditional recommendations, they can be verbose, hard to follow, and require outside state. I think tests should be concise, read like a story, and not require some kind of known initial state (i.e. pre-loaded database fixtures). I'd like to put a spotlight on several 3rd party bundles (some maintained by me) and show how they can be used to improve your testing experience. I'll demonstrate how easy and fun creating a new feature for your app can be using these bundles and TDD.

Finally, I'll share my ideas for a "Symfony Testing Initiative" that I'm spearheading to bring these excellent testing tools into Symfony core (as both code and official docs/best practices).

Kevin Bond

June 01, 2024
Tweet

More Decks by Kevin Bond

Other Decks in Programming

Transcript

  1. Me? From Ontario, Canada Husband, father of three Symfony user

    since 1.0 Symfony Core Team Symfony UX Team @kbond on GitHub/Slack @zenstruck on Twitter
  2. zenstruck? A GitHub organization where my open source packages live

    zenstruck/foundry zenstruck/browser zenstruck/messenger-test zenstruck/filesystem (wip) zenstruck/messenger-monitor-bundle (wip) ... Many co-maintained by Nicolas PHILIPPE ( @nikophil )
  3. What we'll cover Testing helper libraries My testing style Combining

    above libraries in a test Symfony Testing Initiative Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  4. foundry composer require --dev zenstruck/foundry https://github.com/zenstruck/foundry Object (ORM entity/Mongo document)

    factories One factory class per object Fixture loading Reset database between tests Repository assertions Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  5. foundry namespace App\Tests; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; final

    class FoundryTest extends KernelTestCase { use Factories, ResetDatabase; } Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  6. foundry public function test_user_registration(): void { UserFactory::assert()->empty(); // logic to

    register a user (with email: [email protected])... UserFactory::assert() ->count(1) ->exists(['email' => '[email protected]']) ; } Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  7. foundry public function test_user_profile_update(): void { $user = UserFactory::createOne(['email' =>

    '[email protected]']); // logic to update a user's email (to: [email protected])... // $user is auto-refreshed! $this->assertSame('[email protected]', $user->getEmail()); } Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  8. foundry 2.0 (coming soon) Full rewrite and code cleanup 1.x

    to 2.x migration path (including rector rules) Real proxies using Symfony's Lazy Proxies Option for different factory types: Plain Object (no persistence) Persistent Object (not proxied) Proxied Object Array? Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  9. browser composer require --dev zenstruck/browser https://github.com/zenstruck/browser Fluent API for functional/E2E

    tests Json selector API (using JMESPath) Option to use Panther for JS testing Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  10. browser namespace App\Tests; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Browser\Test\HasBrowser; final class BrowserTest

    extends KernelTestCase { use HasBrowser; } Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  11. browser public function test_user_registration(): void { $this->browser() ->visit('/register') ->fillField('Name', 'Kevin')

    ->fillField('Email', '[email protected]') ->clickAndIntercept('Register') ->assertRedirectedTo('/profile') ->assertSuccessful() ->assertSeeIn('h1', 'Welcome Kevin!') ; } Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  12. browser public function test_api_get_podcast_endpoint(): void { $this->browser() ->get('/podcast/1') ->assertJson() ->assertJsonMatches('metadata.title',

    'Podcast 1') // JMESPath ->assertJsonMatches('metadata.duration', 2325) ; } Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  13. browser public function test_podcast_player(): void { $this->pantherBrowser() ->visit('/player') ->click('Play Podcast

    1') ->waitUntilSeeIn('.player', 'Playing Podcast 1') ; } Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  14. browser 2.0 (coming soon) Total rewrite of the DOM selector/manipulation

    API which will allow for more powerful element selections. Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  15. browser 2.0 (coming soon) $browser->click(function(Dom $dom) { return $dom->find('.product-table td:contains("Product

    1")') ->closest('tr') ->descendant(Selector::link('Edit')) ; }); Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  16. messenger-test composer require --dev zenstruck/messenger-test https://github.com/zenstruck/messenger-test Assert that messages are

    dispatched/queued/handled Process queued messages (either automatically or on-demand) Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  17. messenger-test namespace App\Tests; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Messenger\Test\InteractsWithMessenger; final class MessengerTest

    extends KernelTestCase { use InteractsWithMessenger; } Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  18. messenger-test public function test_processing_podcasts(): void { $this->transport()->queue()->assertEmpty(); // optional //

    ...operation that adds ProcessPodcast message to your queue $this->transport()->queue() ->assertCount(1) ->assertContains(ProcessPodcast::class) ; } Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  19. messenger-test // ... // process first message on the queue

    $this->transport()->processOrFail(1); Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  20. mailer-test composer require --dev zenstruck/mailer-test https://github.com/zenstruck/mailer-test Alternative to Symfony's KernelTestCase::assertEmail*()

    methods Fluent API for testing sent emails (count, recipients, etc.) Per-message assertions (cc, subject, body content, etc.) Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  21. mailer-test when@test: framework: mailer: dsn: null://null # optional but improves

    test speed Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  22. mailer-test namespace App\Tests; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Mailer\Test\InteractsWithMailer; final class MailerTest

    extends KernelTestCase { use InteractsWithMailer; } Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  23. mailer-test public function test_contact_form(): void { // ... some operation

    that sends an email to [email protected] $this->mailer() ->assertSentEmailCount(1) ->assertEmailSentTo('[email protected]', function(TestEmail $email) { // ... }) ; } Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  24. mailer-test ->assertEmailSentTo('[email protected]', function(TestEmail $email) { $email ->assertSubject('Contact Form Submission') ->assertContains('Thanks

    for getting in touch!') // both text and html ->assertHasFile('cats.png', 'image/png') ->assertHasTag('contact-form') ; }) Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  25. mailer-test $this->mailer()->sentEmails() ->whereSubject('Contact Form Submission') // filters ->whereReplyTo('[email protected]') ->assertCount(1) ->first()

    ->assertContains('Thanks for getting in touch!') ->assertHasFile( expectedFilename: 'cats.png', expectedContentType: 'image/png', expectedContents: file_get_contents(FIXTURES_DIR.'/cats.png') ) ; Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  26. mailer-test 2.0 (coming soon) DOM-based HTML email content assertions (similar

    to zenstruck/browser ). For example, this will enable you to assert that your HTML email's h1 tag contains specific text. Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  27. My Testing Style Readable Self-contained Outside-in AAA: Arrange (configure the

    current test's world state) Pre-Assert (optional, setup expectations) Act (perform the operation) Assert (verify the operation's result) Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  28. My Test Structure tests/Unit extends TestCase follows src/ directory structure

    tests/Integration extends KernelTestCase follows src/ directory structure Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  29. My Test Structure tests/Functional extends WebTestCase (or KernelTestCase ) named/grouped

    by feature (e.g. User\RegistrationTest.php ) Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  30. All together now! final class UploadPodcastTest extends KernelTestCase { use

    InteractsWithMessenger; use InteractsWithMailer; use HasBrowser; use Factories, ResetDatabase; } Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  31. All together now! public function test_upload_podcast_flow(): void { PodcastFactory::assert()->empty(); //

    pre-assertion $browser = $this->browser() ->actingAs(UserFactory::createOne(['email' => '[email protected]'])) ->visit('/podcasts/upload') ->fillField('Title', 'First Week at SymfonyCasts') ->attachFile('File', FIXTURE_DIR.'/podcasts/62.mp3') ->click('Upload') ->assertRedirectedTo('/podcasts') ->assertSee('First Week at SymfonyCasts (processing)') ; // ... Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  32. All together now! // ... $podcast = PodcastFactory::first(); $this->assertSame('First Week

    at SymfonyCasts', $podcast->getTitle()); $this->assertFalse($podcast->isProcessed()); $this->assertNull($podcast->getDuration()); $this->mailer()->assertEmailSentTo( '[email protected]', 'Processing Podcast "First Week at SymfonyCasts"', ); // ... Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  33. All together now! // ... $this->transport() ->queue()->assertContains(ProcessPodcast::class, times: 1)->back() ->processOrFail()

    ; $this->assertTrue($podcast->isProcessed()); $this->assertSame(62, $podcast->getDuration()) // ... Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  34. All together now! // ... $this->mailer()->assertEmailSentTo( '[email protected]', 'Successfully Processed Podcast

    "First Week at SymfonyCasts"', ); $browser ->visit('/podcasts') ->assertSee('First Week at SymfonyCasts (processed)') ; Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  35. console-test composer require --dev zenstruck/console-test https://github.com/zenstruck/console-test Alternate to Symfony's CommandTester

    Fluent API to have your console tests read like a story Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  36. console-test namespace App\Tests; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Console\Test\InteractsWithConsole; final class UpdatePodcastsCommandTest

    extends KernelTestCase { use InteractsWithConsole; } Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  37. console-test public function test_can_update_all_podcasts(): void { $this->executeConsoleCommand('podcast:update --all') ->assertSuccessful() ->assertOutputContains('Updating

    all podcasts...') ->assertOutputContains('Done!') ; } Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  38. console-test public function test_can_update_single_podcast_interactively(): void { $this->executeConsoleCommand( 'podcast:update', inputs: ['Podcast

    1', 'apple']) ->assertSuccessful() ->assertOutputContains('Updating Podcast 1...') ->assertOutputContains('Sending to Apple...') ->assertOutputNotContains('Sending to Spotify...') ->assertOutputContains('Done!') ; } Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  39. dependency-injection-test composer require --dev matthiasnoback/symfony-dependency- injection-test https://github.com/SymfonyTest/SymfonyDependencyInjectionTest Test bundle extensions

    Test bundle configuration - especially useful if your bundle has complex configuration (normalizers and validators) Compiler passes Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond
  40. Symfony Testing Initiative "This should be in Symfony Core" zenstruck/*

    considered POC Goal: make Symfony testing easier Help from Nicolas PHILIPPE ( @nikophil ) Crafting Elegant Symfony Tests Kevin Bond • @zenstruck • github.com/kbond