Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

Testing Absolute Units

pelshoff
January 19, 2023

Testing Absolute Units

Isn't writing tests hard? As much as we believe in testing, tests make our life difficult. Too much boilerplate, too rigid, tee dee dee is not for me. Don't you wonder sometimes: what am I even writing tests for?

Worry not! Fear no more! So do I! Fortunately there are tricks that can make our life easy. In fact, with the right approach, tests are the only thing you have to think about... Wouldn't you love to just chill like that?

Let's discuss the pain of development without and with tests. Let's deal with testing Absolute Units.

pelshoff

January 19, 2023
Tweet

More Decks by pelshoff

Other Decks in Programming

Transcript

  1. @pelshoff @pelshoff Learning “In software development learning is not a

    big part of the job. It is the job.” – Woody Zuill https://twitter.com/WoodyZuill/status/1502366061890473985 Photo by Daniel Mingook Kim on Unsplash
  2. @pelshoff @pelshoff Confidence “Testing is all about confidence. When I'm

    working, I need to be confident that I haven't broken anything, and that the code that I just wrote works as expected. When I'm working, that's ALL I need to test for.” – Allen Holub https://twitter.com/allenholub/status/1538561251868954624 Photo by Daniel Mingook Kim on Unsplash
  3. @pelshoff @pelshoff Consider... What is there to do? What is

    left to do? How should the system behave? How should the component behave? How should the component internals work? Photo by Daniel Mingook Kim on Unsplash
  4. @pelshoff @pelshoff Transform … to natural language spec … to

    executable spec … to integration test … to unit test … to code Photo by Daniel Mingook Kim on Unsplash
  5. @pelshoff @pelshoff /tests/features/Policy/Adjustment/nonSchemeMta.feature Feature: non-scheme mid-term adjustments In order to

    match my insurance policy to my evolved situation As an insured party I want to adjust my policy mid-term Scenario: Annualised (full term) mta Given a non-scheme policy And the start date is "2019-01-01" And the premium is 365 GBP And the policy is bound And the policy is opened When the effective date is "2019-07-01" And the mta is 375 GBP annualised Then delta gross premium should be 5.04 GBP Scenario: Pro rated mta ... Photo by Osman Rana on Unsplash
  6. @pelshoff @pelshoff What is a non-scheme mid term adjustment? Photo

    by Osman Rana on Unsplash • Scheme: We sell an insurance product on behalf of an insurance company (underwriter) • Non-scheme: We sell an insurance product the insurance company sells (broker) • Policy: A contract between a customer and us that said customer is covered for certain perils pertaining exposures for certain periods of time • Term: One of a certain period of time, usually either a month or a year • Mid term adjustment: an adjustment while a term is ongoing • Effective date: The date this “version” if the policy goes live • Premium: The fee a customer pays... • Term premium: for all transactions combined • Current premium: for the latest transaction of a would be term • Delta premium: for the latest transaction..?
  7. @pelshoff @pelshoff What is a non-scheme mid term adjustment? Photo

    by Osman Rana on Unsplash • Non-scheme: We don’t decide the premium • Mid term adjustment: for this adjustment to the contract • Premium: and insurance companies might give us current ór delta • Current premium: where current is the price for a full term (and for the next term upon renewal) • Delta premium: and delta is ehm
  8. @pelshoff @pelshoff What is a delta premium? Photo by Osman

    Rana on Unsplash • You pay 365GBP for a one year policy • You change to a 375GBP premium half-way through • The delta is NOT 10GBP • You’ve paid 365 / ~2 too much • You’ve paid 375 / ~2 too little • 375 / ~2 - 365 / ~2 = ? • ~187.5 - ~182.5 = 5.04
  9. @pelshoff @pelshoff /tests/features/Policy/Adjustment/nonSchemeMta.feature Feature: non-scheme mid-term adjustments In order to

    match my insurance policy to my evolved situation As an insured party I want to adjust my policy mid-term Scenario: Annualised (full term) mta Given a non-scheme policy And the start date is "2019-01-01" And the premium is 365 GBP And the policy is bound And the policy is opened When the effective date is "2019-07-01" And the mta is 375 GBP annualised Then delta gross premium should be 5.04 GBP Photo by Osman Rana on Unsplash
  10. @pelshoff @pelshoff .../nonSchemeMta.feature | .../NonSchemeMtaContext.php And the start date is

    "2019-01-01" Photo by Osman Rana on Unsplash /** * @Given /^the start date is "2019\-01\-01"$/ */ public function theStartDateIs ($arg1) { throw new PendingException() ; } /** * @Given the start date is :aDate */ public function theStartDateIs ($aDate) { throw new PendingException() ; }
  11. @pelshoff @pelshoff .../nonSchemeMta.feature | .../NonSchemeMtaContext.php And the start date is

    "2019-01-01" Photo by Osman Rana on Unsplash /** * @Given the start date is :aDate */ public function theStartDateIs ($aDate) { $this->policy->updateStartDate ( LocalDate:: parse( trim( $aDate, '"') ) ); }
  12. @pelshoff @pelshoff .../nonSchemeMta.feature | .../NonSchemeMtaContext.php Then delta gross premium should

    be 5.04 GBP Photo by Osman Rana on Unsplash ** * @Then delta gross premium should be :amount :currency */ public function deltaGrossPremiumShouldBe ( $amount, $currency ){ $this->policy->assertRecorded ( new PremiumTransacted( Money:: GBP(5_04) ) ); }
  13. @pelshoff @pelshoff Terminal $ vendor/bin/behat Feature: non-scheme mid-term adjustments In

    order to match my insurance policy to my evolved situation As an insured party I want to adjust my policy mid-term Scenario: Annualised (full term) mta Given a non-scheme policy TODO: write pending definition And the start date is "2019-01-01" And the premium is 365 GBP And the policy is bound And the policy is opened When the effective date is "2019-07-01" And the mta is 375 GBP annualised Then delta gross premium should be 5.04 GBP 1 scenario (1 pending) 8 steps (1 pending, 7 skipped) Photo by Osman Rana on Unsplash
  14. @pelshoff @pelshoff Terminal $ vendor/bin/behat 1 scenario (1 failed) 8

    steps (7 passed, 1 failed) Photo by Osman Rana on Unsplash
  15. @pelshoff @pelshoff Terminal $ vendor/bin/behat 1 scenario (1 passed) 8

    steps (8 passed) Photo by Osman Rana on Unsplash
  16. @pelshoff @pelshoff /tests/Features/Policy/Adjustment/NonSchemeMtaTest.php Photo by Osman Rana on Unsplash /**

    * In order to match my insurance policy to my evolved situation * As an insured party * I want to adjust my policy mid-term */ public function it_calculates_non_scheme_mta_annualised (): void { Policy::fake() ->givenNonSchemePolicy () ->givenStartDateIs (2019, 1, 1) ->givenPremiumIs (365, 'GBP') ->givenPolicyIsBound () ->givenPolicyIsOpened () ->whenEffectiveDateIs (2019, 7, 1) ->whenMtaIs(375, 'GBP', 'annualised') ->thenDeltaGrossPremiumShouldBe (5.04, 'GBP'); }
  17. @pelshoff @pelshoff Heuristics Don’t put too many (unimportant) details in

    feature tests Keep the tests high-level Photo by Osman Rana on Unsplash
  18. @pelshoff @pelshoff Top 5 challenges with tests 5. Scope mix-up

    4. Mocks 3. Too much, inconsistent or non-deterministic setup 2. No tests 1. Other devs Photo by Matt Cramblett on Unsplash
  19. @pelshoff @pelshoff Top 5 challenges with tests 5. Scope mix-up

    4. Mocks 3. Too much, inconsistent or non-deterministic setup 2. No tests 1. Other devs Design of the “Unit” Photo by Matt Cramblett on Unsplash
  20. @pelshoff @pelshoff class PremiumCalculation { public function act(PremiumCalculationAction $action): static

    { $action->assertActionCanBeApplied ($this->transactions, $this->period); return $this->withTransaction ($action->transact($this->transactions, $this->period)); } public function correct(PremiumCalculationCorrection $correction): static { $this->transactions->assertThereAreTransactions (); $correction->assertCorrectionCanBeApplied ($this->transactions, $this->period); $correctedCalculation = $this->withTransaction ( LastTransactionPremiumReversal:: for($this->transactions) ); return $correctedCalculation ->withTransaction ( CorrectionTransaction:: fromTransaction( $correction->transact($correctedCalculation ->transactions, $this->period) ) ); } } src/Premium/PremiumCalculation.php Photo by Matt Cramblett on Unsplash
  21. @pelshoff @pelshoff /** @test */ public function can_adjust() { $bindPremium

    = new SinglePremium(Money:: GBP(365_00), ...); $adjustPremium = new SinglePremium(Money:: GBP(375_00), ...); $deltaPremium = new SinglePremium(Money:: GBP(5_04), ...); $expected = Transactions:: of([ new PremiumTransaction(LocalDate:: of(2019, 1, 1), $bindPremium, $bindPremium), new PremiumTransaction(LocalDate:: of(2019, 7, 1), $adjustPremium , $deltaPremium), ]); $actual = Transactions:: of( PremiumCalculation:: for(TermPeriod::of(...)) ->act(new CurrentPremiumBind( $bindPremium)) ->act(new CurrentPremiumAdjustment(LocalDate:: of(2019, 7, 1), $adjustPremium )) ->newTransactions () ); $this->assertEquals($expected, $actual); } tests/Premium/PremiumCalculationTest.php Photo by Matt Cramblett on Unsplash
  22. @pelshoff @pelshoff class PremiumCalculationTest extends TestCase { public function is_immutable(){}

    public function wont_bind_when_there_are_already_transactions (){} public function can_bind(){} public function needs_existing_transactions_before_adjusting (){} public function can_adjust(){} public function can_bind_then_adjust_multiple_times (){} public function can_correct_a_bind (){} public function wont_correct_with_a_new_bind_if_an_adjustment_happened (){} public function can_correct_an_adjustment (){} } tests/Premium/... Photo by Matt Cramblett on Unsplash
  23. @pelshoff @pelshoff class PremiumCalculationTest extends TestCase { public function is_immutable(){}

    public function can_bind_then_adjust_multiple_times (){} public function can_correct_a_bind (){} public function wont_correct_with_a_new_bind_if_an_adjustment_happened (){} public function can_correct_an_adjustment (){} } class CurrentPremiumAdjustmentTest extends TestCase { public function needs_existing_transactions (){} public function can_only_append_transactions (){} public function must_be_within_term_bounds (){} public function can_adjust_to_a_higher_current_premium (){} public function can_adjust_to_a_lower_current_premium (){} public function can_adjust_on_the_first_day (){} public function can_adjust_on_the_final_day (){} } class TransactionsTest extends TestCase { public function can_determine_term_premium (){} } tests/Premium/... Photo by Matt Cramblett on Unsplash
  24. @pelshoff @pelshoff $ vendor/bin/phpunit --testdox Premium Calculation (Tests\...\PremiumCalculation) ✔ Is

    immutable ✔ Can bind then adjust multiple times ✔ Can correct a bind ✔ Wont correct with a new bind if an adjustment happened ✔ Can correct an adjustment Current Premium Adjustment (Tests\...\CurrentPremiumAdjustment) ✔ Needs existing transactions ✔ Can only append transactions ✔ Must be within term bounds ✔ Can adjust to a higher current premium ✔ Can adjust to a lower current premium ✔ Can adjust on the first day ✔ Can adjust on the final day Transactions (Tests\...\Transactions) ✔ Can determine term premium Terminal Photo by Matt Cramblett on Unsplash
  25. @pelshoff @pelshoff 1. Fix the feature test 2. Fix the

    integration test 3. Fix the unit test 4. Fix the integration test 5. Fix the feature test 1. Make it work 2. Make it beautiful 3. (Make it fast) Executable to-do list Photo by Matt Cramblett on Unsplash
  26. @pelshoff @pelshoff /** @test */ public function can_adjust() { $bindPremium

    = new SinglePremium(Money:: GBP(365_00), ...); $adjustPremium = new SinglePremium(Money:: GBP(375_00), ...); $deltaPremium = new SinglePremium(Money:: GBP(5_04), ...); $expected = Transactions:: of([ new PremiumTransaction(LocalDate:: of(2019, 1, 1), $bindPremium, $bindPremium), new PremiumTransaction(LocalDate:: of(2019, 7, 1), $adjustPremium , $deltaPremium), ]); $actual = Transactions:: of( PremiumCalculation:: for(TermPeriod::of(...)) ->act(new CurrentPremiumBind( $bindPremium)) ->act(new CurrentPremiumAdjustment(LocalDate:: of(2019, 7, 1), $adjustPremium )) ->newTransactions () ); $this->assertEquals($expected, $actual); } tests/Premium/PremiumCalculationTest.php Photo by Matt Cramblett on Unsplash
  27. @pelshoff @pelshoff Executable to-do list Error : Class "TestingAbsoluteUnits\Tests\Unit\PremiumCalculation" not

    found Error : Call to undefined method TestingAbsoluteUnits\Premium\PremiumCalculation::forTerm() Error : Call to undefined method TestingAbsoluteUnits\Premium\PremiumCalculation::act(), etc. TypeError : TestingAbsoluteUnits\Premium\PremiumCalculation::act(): Argument #1 ($param) must be of type TestingAbsoluteUnits\Tests\Unit\CurrentPremiumAdjustment, TestingAbsoluteUnits\Premium\Adjustment\CurrentPremiumAdjustment given PremiumCalculation::newTransactions(): Return value must be of type array, null returned Failed asserting that two objects are equal. Photo by Matt Cramblett on Unsplash
  28. @pelshoff @pelshoff /** @test */ public function can_adjust() { $bindPremium

    = new SinglePremium(Money:: GBP(365_00), ...); $adjustPremium = new SinglePremium(Money:: GBP(375_00), ...); $deltaPremium = new SinglePremium(Money:: GBP(5_04), ...); $expected = Transactions:: of([ new PremiumTransaction(LocalDate:: of(2019, 1, 1), $bindPremium, $bindPremium), new PremiumTransaction(LocalDate:: of(2019, 7, 1), $adjustPremium , $deltaPremium), ]); $actual = Transactions:: of( PremiumCalculation:: for(TermPeriod::of(...)) ->act(new CurrentPremiumBind( $bindPremium)) ->act(new CurrentPremiumAdjustment(LocalDate:: of(2019, 7, 1), $adjustPremium )) ->newTransactions () ); $this->assertEquals($expected, $actual); } tests/Premium/PremiumCalculationTest.php Photo by Matt Cramblett on Unsplash
  29. @pelshoff @pelshoff Executable to-do list [] vs. [0 => PremiumTransaction

    Object (), 1 => PremiumTransaction Object ()] [...] vs. [..., 1 => PremiumTransaction Object ()] Error : Call to undefined method TestingAbsoluteUnits\Premium\Adjustment\CurrentPremiumAdjustment::transact() [..., ...37500...] vs. [..., ...504...] The form of the results is now the same. What’s next? Photo by Matt Cramblett on Unsplash
  30. @pelshoff @pelshoff /** @test */ public function can_adjust_to_a_higher_current_premium () {

    $bindPremium = new SinglePremium(Money:: GBP(365_00), ...); $adjustPremium = new SinglePremium(Money:: GBP(375_00), ...); $deltaPremium = new SinglePremium(Money:: GBP(5_04), ...); $bindDate = LocalDate::of(2019, 1, 1); $adjustDate = LocalDate::of(2019, 7, 1); $adjustment = new CurrentPremiumAdjustment( $adjustDate, $adjustPremium ); $expected = new PremiumTransaction( $adjustDate, $adjustPremium , $deltaPremium); $actual = $adjustment->transact( Transactions:: fromBind($bindDate, $bindPremium), TermPeriod::fullYearOf($adjustDate) ); $this->assertEquals($expected, $actual); } tests/Premium/Calculation/Adjustment/CurrentPremiumAdjustmentTest.php Photo by Matt Cramblett on Unsplash
  31. @pelshoff @pelshoff public function transact(Transactions $transactions, TermPeriod $period): Transaction {

    // Given the number of days we have left // Take what we've paid so far // Subtract what we've paid too much for the old policy // Add what we pay for the new policy $effectiveNumberOfDaysForNewCurrentPremium = $this->effectiveDate-> daysUntil ($period->exclusiveEndDate ()); $deltaPremium = $transactions->termPremium() ->multiply($period->fullTermDays()) ->subtract( $transactions ->currentPremium () -> multiply($effectiveNumberOfDaysForNewCurrentPremium ) ) ->add($this->premium->multiply($effectiveNumberOfDaysForNewCurrentPremium )) ->divide($period->fullTermDays()) ->subtract($transactions->termPremium()); return new PremiumTransaction( $this->effectiveDate, $this->premium, $deltaPremium); } src/Premium/Calculation/Adjustment/CurrentPremiumAdjustment.php Photo by Matt Cramblett on Unsplash
  32. @pelshoff @pelshoff Heuristics Photo by Matt Cramblett on Unsplash A

    unit is a component you can test in isolation; Write tests with isolation and no mocks to create units Fight the urge to automate and/or abstract test set-up; if it’s hard make it smaller Large units are hard to TDD; if it’s hard make it smaller Keep your suite fast and Run The Tests Use test failures as a to-do list Highlight what’s special Highlight what’s hard
  33. @pelshoff @pelshoff sum.php | sumTest.php function sum(int $x, int $y)

    { return $x + $y; } $this->assertEquals(4, sum(2, 2)); Photo by Maksym Kaharlytskyi on Unsplash
  34. @pelshoff @pelshoff divide.php | divideTest.php function divide(int $x, int $y):

    float { return $x / $y; } $this->assertEquals(2, divide(4, 2)); Photo by Maksym Kaharlytskyi on Unsplash
  35. @pelshoff @pelshoff divide.php | divideTest.php function divide(int $x, int $y):

    float { if ($y === 0) { throw new DivisorFail(); } return $x / $y; } $this->expectException ( DivisorFail:: class ); divide(1, 0); Photo by Maksym Kaharlytskyi on Unsplash
  36. @pelshoff @pelshoff Transactions.php | TransactionsTest.php public function sum(): int {

    if (count($this->transactions) <= 0) { return 0; } return array_reduce( $this->transactions, fn(int $sum, int $transaction) => $sum + $transaction, 0 ); } public function it_sums_to_zero (): void { $transactions = new Transactions([]) ; $this->assertEquals( 0, $transactions ->sum() ); } public function it_sums_good(): void { $transactions = new Transactions( [3,4,5] ); $this->assertEquals( 12, $transactions ->sum() ); } Photo by Maksym Kaharlytskyi on Unsplash
  37. @pelshoff @pelshoff Terminal $ XDEBUG_MODE=coverage vendor/bin/infection .: killed, M: escaped,

    U: uncovered, E: fatal error, X: syntax error, T: timed out, S: skipped, I: ignored M........ (9 / 9) 9 mutations were generated: 8 mutants were killed 0 mutants were configured to be ignored 0 mutants were not covered by tests 1 covered mutants were not detected 0 errors were encountered 0 syntax errors were encountered 0 time outs were encountered 0 mutants required more time than configured Metrics: Mutation Score Indicator (MSI): 88% Mutation Code Coverage: 100% Covered Code MSI: 88% Please note that some mutants will inevitably be harmless (i.e. false positives). Photo by Maksym Kaharlytskyi on Unsplash
  38. @pelshoff @pelshoff infection.log Escaped mutants: ================ 1) Transactions.php:17 [M] LessThanOrEqualTo

    --- Original +++ New @@ @@ } public function sum() : int { - if (count($this->transactions) <= 0) { + if (count($this->transactions) < 0) { return 0; } return array_reduce($this->transactions, fn(int $sum, int $transaction) => $sum + $transaction, 0); } } Photo by Maksym Kaharlytskyi on Unsplash
  39. @pelshoff @pelshoff Transactions.php | TransactionsTest.php public function sum(): int {

    if (count($this->transactions) <= 0) { return 0; } return array_reduce( $this->transactions, fn(int $sum, int $transaction) => $sum + $transaction, 0 ); } public function it_sums_to_zero (): void { $transactions = new Transactions([]) ; $this->assertEquals( 0, $transactions ->sum() ); } public function it_sums_good(): void { $transactions = new Transactions( [3,4,5] ); $this->assertEquals( 12, $transactions ->sum() ); } Photo by Maksym Kaharlytskyi on Unsplash
  40. @pelshoff @pelshoff infection.json { "$schema": "vendor/infection/infection/resources/schema.json" , "testFrameworkOptions": "--testsuite=Infection", "source":

    { "directories": [ "app" ] }, "mutators": { "@default": true, "@cast": false }, "logs": { "text": "infection.log" } } $ XDEBUG_MODE=coverage vendor/bin/infection --only-covered Photo by Maksym Kaharlytskyi on Unsplash
  41. @pelshoff @pelshoff Infection Mutation testing lib for PHP Analyses source

    tree and runs your suite for each applied mutation Prefer regular PHPUnit TestCase for performance reasons Run on entire suite or on changed files only © Markus Schirp (https://twitter.com/_m_b_j_) Photo by Maksym Kaharlytskyi on Unsplash
  42. @pelshoff @pelshoff Infection On my M1 Macbook 86 test cases

    Infection running with --only-covered for 128 mutants \PHPUnit\TestCase: 0.057s Infection: 14s \Illuminate\Foundation\Testing\TestCase: 4.712s Infection: 48s Photo by Maksym Kaharlytskyi on Unsplash
  43. @pelshoff @pelshoff divide.php | divideTest.php function divide(int $x, int $y):

    float { return $x / $y; } $this->assertEquals(2, divide(4, 2)); Photo by Maksym Kaharlytskyi on Unsplash
  44. @pelshoff @pelshoff divide.php | divideTest.php function divide(int $x, int $y):

    float { return $x / $y; } use Eris\Generators ; use Eris\TestTrait ; $this->forAll( Generators:: int(), Generators:: int() ) ->then(fn(int $x, int $y) => $this->assertIsNumeric ( divide( $x, $y)) ); OutOfBoundsException : Evaluation ratio 0.01 is under the threshold 0.5 Caused by DivisionByZeroError: Division by zero Reproduce with: ERIS_SEED=1646663840606414 vendor/bin/phpunit --filter '...' Photo by Maksym Kaharlytskyi on Unsplash
  45. @pelshoff @pelshoff Eris Property testing lib for PHP “Brute forces”

    towards edge cases Prefer regular PHPUnit TestCase for performance reasons Photo by Maksym Kaharlytskyi on Unsplash