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

Push your side effects to the edge

pelshoff
December 19, 2023

Push your side effects to the edge

You followed all the rules: TDD, SOLID, CUPID and of course DDD. But when refactoring time comes, you still have to touch all those pesky tests.
You thought refactoring meant not changing the tests!?

In this session we'll see how design choices can lead to test pain - and how you can avoid that pain by pushing side effects to the edge. We'll:

- Identify context and side-effects
- Split up different kinds of logic
- Black-box test *all the things*
- ... identify when and when not to!

pelshoff

December 19, 2023
Tweet

More Decks by pelshoff

Other Decks in Programming

Transcript

  1. @pelshoff @pelshoff StockReport public function report(): void { // Find

    all products whose stock and price hasn't been reported recently // Report them to the external service // Mark them as reported }
  2. @pelshoff @pelshoff StockReport public function report(): void { foreach (Product::notRecentlyReported

    ()->cursor() as $product) { $this->client->report($product->ean, $product->stock, $product->price); $product->last_reported_at = date('Y-m-d H:i:s' ); $product->save(); } }
  3. @pelshoff @pelshoff StockReportTest #[Test] public function test_reports_stock_and_price_and_updates_last_reported_at (): void {

    // arrange $product = Product::factory()->create(['ean' => 'a1', 'stock' => 3, 'price' => '+- 3.50']); $client = $this->createMock(StockReportClient ::class); $client->expects($this->once()) ->method('report') ->with('a1', 3, '+- 3.50'); $report = new StockReport ($client); // act $report->report(); // assert-ish $product->refresh(); $this->assertEquals(date('Y-m-d H:i:s' ), $product->last_reported_at ); }
  4. @pelshoff @pelshoff StockReportTest #[Test] public function reports_nothing_when_all_products_are_recently_reported (): void {

    // arrange Product::factory()->create(['last_reported_at' => date('Y-m-d H:i:s' )]); $client = $this->createMock(StockReportClient ::class); $client->expects($this->never()) ->method('report'); $report = new StockReport ($client); // act $report->report(); }
  5. @pelshoff @pelshoff StockReportTest #[Test] public function shuts_down_on_client_error (): void {

    // arrange Product::factory()->create(); $client = $this->createMock(StockReportClient ::class); $client->expects($this->once()) ->method('report') ->willThrowException(new RuntimeException()); $report = new StockReport ($client); // assert $this->expectException(StockReportException::class); // act $report->report(); }
  6. @pelshoff @pelshoff StockReport /** * @throws StockReportException */ public function

    report(): void { foreach (Product::notRecentlyReported ()->cursor() as $product) { try { $this->client->report($product->ean, $product->stock, $product->price); } catch (Exception $e) { throw new StockReportException('', 0, $e); } $product->last_reported_at = date('Y-m-d H:i:s' ); $product->save(); } }
  7. @pelshoff @pelshoff StockReportTest /unit /StockReportTest.php + reports_stock_and_price_and_updates_last_reported_at + reports_nothing_when_all_products_are_recently_reported +

    shuts_down_on_client_error Stock Report (My\Project\StockReport) ✔ Reports stock and price and updates last reported at ✔ Reports nothing when all products are recently reported ✔ Shuts down on client error Structure and Interpretation of Test Cases • Kevlin Henney • GOTO 2022 (https://www.youtube.com/watch?v=MWsk1h8pv2Q)
  8. @pelshoff @pelshoff StockReportTest /** * @throws StockReportException */ public function

    report(): void { foreach (Product::notRecentlyReported ()->cursor() as $product) { try { $this->client->report($product->ean, $product->stock, $product->price); } catch (Exception $e) { throw new StockReportException ('', 0, $e); } $product->last_reported_at = date('Y-m-d H:i:s' ); $product->save(); } }
  9. @pelshoff @pelshoff All problems in computer science can be solved

    by another level of indirection – David Wheeler
  10. @pelshoff @pelshoff StockReportTest public function someFunction (Container $container): void {

    $object = $container->make(SomeClass::class); $otherObject = App::make(SomeClass::class); }
  11. @pelshoff @pelshoff StockReport /** * @throws StockReportException */ public function

    report(): void { foreach ($this->repository->notRecentlyReported() as $product) { try { $this->client->report($product->ean, $product->stock, $product->price); } catch (Exception $e) { throw new StockReportException ('', 0, $e); } $product->last_reported_at = date('Y-m-d H:i:s' ); $this->repository->save($product); } }
  12. @pelshoff @pelshoff StockReportTest (integration) #[Test] public function persists_correctly (): void

    { // arrange $product = Product::factory()->create(); $client = $this->createMock(StockReportClient ::class); $client->expects($this->any()); $report = new StockReport (new EloquentProductRepository(), $client); // act $report->report(); // assert-ish $product->refresh(); $this->assertEquals (date('Y-m-d H:i:s' ), $product->last_reported_at ); }
  13. @pelshoff @pelshoff StockReportTest (unit) #[Test] public function test_reports_stock_and_price_and_updates_last_reported_at (): void

    { // arrange $product = Product::factory()->make(['ean' => 'a1', 'stock' => 3, 'price' => '+- 3.50']); $collection = new LazyCollection([$product]); $repository = $this->createMock(ProductRepository::class); $repository->expects($this->once()) ->method('notRecentlyReported') ->willReturn($collection); $client = $this->createMock(StockReportClient ::class); $client->expects($this->once()) ->method('report') ->with('a1', 3, '+- 3.50'); $report = new StockReport ($repository, $client); // act // ...
  14. @pelshoff @pelshoff StockReport /** * @throws StockReportException */ public function

    report(): void { foreach ($this->repository->notRecentlyReported () as $product) { try { $this->client->report($product->ean, $product->stock, $product->price); } catch (Exception $e) { throw new StockReportException ('', 0, $e); } $product->last_reported_at = date('Y-m-d H:i:s' ); $this->repository->save($product); } }
  15. @pelshoff @pelshoff StockReport /** * @throws StockReportException */ public function

    report(): void { foreach ($this->repository->notRecentlyReported () as $product) { try { $this->client->report($product->ean, $product->stock, $product->price); } catch (Exception $e) { throw new StockReportException ('', 0, $e); } $product->last_reported_at = date('Y-m-d H:i:s' ); $this->repository->save($product); } }
  16. @pelshoff @pelshoff StockReport / SingleProductReport public function report(): void {

    foreach ($this->repository->notRecentlyReported () as $product) { $this->productReport ->report($product); $this->repository->save($product); } } public function report(Product $product): void { try { $this->client->report($product->ean, $product->stock, $product->price); } catch (Exception $e) { throw new StockReportException ('', 0, $e); } $product->last_reported_at = date('Y-m-d H:i:s' ); }
  17. @pelshoff @pelshoff tests/ /unit /SingleProductReportTest.php + reports_stock_and_price_and_updates_last_reported_at + shuts_down_on_client_error /StockReportTest.php

    + reports_nothing_when_all_products_are_recently_reported /integration /StockReportTest.php + persists_correctly
  18. @pelshoff @pelshoff Single Responsibility Principle • What is single? •

    What is a responsibility? • What is a principle?
  19. @pelshoff @pelshoff StockReport /** * @throws StockReportException */ public function

    report(): void { foreach (Product::notRecentlyReported ()->cursor() as $product) { try { $this->client->report($product->ean, $product->stock, $product->price); } catch (Exception $e) { throw new StockReportException ('', 0, $e); } $product->last_reported_at = date('Y-m-d H:i:s' ); $product->save(); } }
  20. @pelshoff @pelshoff StockReport /** * @throws StockReportException */ public function

    report(): void { foreach (Product::notRecentlyReported ()->cursor() as $product) { try { $this->client->report($product->ean, $product->stock, $product->price); } catch (Exception $e) { throw new StockReportException ('', 0, $e); } $product->last_reported_at = date('Y-m-d H:i:s' ); $product->save(); } } Integration Operation Segregation Principle (https://ralfwestphal.substack.com/p/integration-operation-segregation)
  21. @pelshoff @pelshoff Replacing the rear light casing on a BMW

    Z3 • Unscrew old casing (no need for tool) • Take out old casing • Switch inside to new casing • Put in new casing • Screw the new casing
  22. @pelshoff @pelshoff Replacing the antenna on a Corvette C4 •

    Be lucky to have already taken out • Exhaust • Rear wheel (correct one, left) • ?? • Profit! https://www.youtube.com/watch?v=tso9u7jbcKg (12:20)
  23. @pelshoff @pelshoff StockReport /** * @throws StockReportException */ public function

    report(): void { foreach (Product::notRecentlyReported ()->cursor() as $product) { try { $this->client->report($product->ean, $product->stock, $product->price); } catch (Exception $e) { throw new StockReportException ('', 0, $e); } $product->last_reported_at = date('Y-m-d H:i:s' ); $product->save(); } } Integration Operation Segregation Principle (https://ralfwestphal.substack.com/p/integration-operation-segregation)
  24. @pelshoff @pelshoff StockReport / SingleProductReport public function report(): void {

    foreach ($this->repository->notRecentlyReported () as $product) { $this->productReport ->report($product); $this->repository->save($product); } } public function report(Product $product): void { try { $this->client->report($product->ean, $product->stock, $product->price); } catch (Exception $e) { throw new StockReportException ('', 0, $e); } $product->last_reported_at = date('Y-m-d H:i:s' ); }
  25. @pelshoff @pelshoff StockReport public function report(): void { ... }

    public function report(Product $product): void { try { $response = $this->client->report($product->ean, $product->stock, $product->price); } catch (Exception $e) { throw new StockReportException ('', 0, $e); } if ($response->active) { $product->price = $response->newPrice; } else { $product->active = false; } $product->last_reported_at = date('Y-m-d H:i:s' ); }
  26. @pelshoff @pelshoff StockReport public function report(): void { ... }

    public function report(Product $product): void { try { $response = $this->client->report($product->ean, $product->stock, $product->price); } catch (Exception $e) { throw new StockReportException ('', 0, $e); } if ($response->active) { $product->price = $response->newPrice; } else { $product->active = false; } $product->last_reported_at = date('Y-m-d H:i:s' ); }
  27. @pelshoff @pelshoff StockReportTest #[Test] public function reports_..._and_updates_last_reported_at_and_price (): void {

    // arrange $product = Product::factory()->make(['ean' => 'a1', 'stock' => 3, 'price' => '+- 3.50']); $client = $this->createMock(StockReportClient ::class); $client->expects($this->once()) ->method('report') ->with('a1', 3, '+- 3.50') ->willReturn(new StockReportResponse(true, '+- 4.50')); $report = new SingleProductReport ($client); // act $report->report($product); // assert-ish $this->assertEquals('+- 4.50', $product->price); $this->assertEquals (date('Y-m-d H:i:s' ), $product->last_reported_at ); }
  28. @pelshoff @pelshoff StockReportTest #[Test] public function reports_..._and_updates_last_reported_at_and_active (): void {

    // arrange $product = Product::factory()->make(['ean' => 'a1', 'stock' => 3, 'price' => '+- 3.50']); $client = $this->createMock(StockReportClient ::class); $client->expects($this->once()) ->method('report') ->with('a1', 3, '+- 3.50') ->willReturn(new StockReportResponse(false)); $report = new SingleProductReport ($client); // act $report->report($product); // assert-ish $this->assertEquals(false, $product->active); $this->assertEqual (date('Y-m-d H:i:s' ), $product->last_reported_at ); }
  29. @pelshoff @pelshoff SingleProductReport / ProductUpdate public function report(Product $product): void

    { try { $response = $this->client->report($product->ean, $product->stock, $product->price); } catch (Exception $e) { throw new StockReportException ('', 0, $e); } $this->productUpdate ->apply($response, $product); } public function apply(StockReportResponse $response, Product $product): void { if ($response->active) { $product->price = $response->newPrice; } else { $product->active = false; } $product->last_reported_at = date('Y-m-d H:i:s' ); }
  30. @pelshoff @pelshoff tests/ /unit /SingleProductReportTest.php + shuts_down_on_client_error /StockReportTest.php + reports_nothing_when_all_products_are_recently_reported

    /ProductUpdateTest.php + updates_last_reported_at_and_price + updates_last_reported_at_and_active /integration /StockReportTest.php + persists_correctly
  31. @pelshoff @pelshoff ProductUpdateTest #[Test] public function updates_last_reported_at_and_price (): void {

    // arrange $product = Product::factory()->make(['ean' => 'a1', 'stock' => 3, 'price' => '+- 3.50']); $response = new StockReportResponse (true, '+- 4.50'); $update = new ProductUpdate (); // act $update->apply($response, $product); // assert-ish $this->assertEquals ('+- 4.50', $product->price); $this->assertEquals (date('Y-m-d H:i:s' ), $product->last_reported_at ); }
  32. @pelshoff @pelshoff ProductUpdateTest #[Test] public function updates_last_reported_at_and_active (): void {

    // arrange $product = Product::factory()->make(['ean' => 'a1', 'stock' => 3, 'price' => '+- 3.50']); $response = new StockReportResponse (false); $update = new ProductUpdate (); // act $update->apply($response, $product); // assert-ish $this->assertEquals (false, $product->active); $this->assertEquals (date('Y-m-d H:i:s' ), $product->last_reported_at ); }
  33. @pelshoff @pelshoff Function Parameters Return value Context! Syntax Exception Semantics

    Exception Action Side effects! Exception https://www.destroyallsoftware.com/screencasts/catalog /functional-core-imperative-shell (13:02)
  34. @pelshoff @pelshoff Current solution • Find a product file that

    has not recently been reported on • Walk across the street • Read out the correct info to the clerk • Check the clock and update the time on the file • Go back across the street and store the file
  35. @pelshoff @pelshoff Much BetterTM solution • Gather all product files

    that have not recently been reported on • Make one report for all those files • Walk across the street • Read out the correct info to the clerk • ... • Check the clock and update the time on the file • Repeat • Go back across the street and gather all files again • Update and store all files
  36. @pelshoff @pelshoff Summary • All design is optimized for something

    • A design is good if it’s optimized for the correct something • Certain symptoms indicate incorrect optimization • Those symptoms are often found in test suites (inverted pyramid, use of mocks, …) • Discomfort with those symptoms is a signal for incorrect optimization • Don’t shoot the messenger, address the cause • The Integration Operation Segregation Principle explains many root causes of mocking • Don’t choose your approach; Choose your discomfort, the approach follows • Write software for humans and about humans (“When in doubt, call Timmy out”)