Generate Edge-Case Tests

isMinimumAge checks whether a candidate is old enough for a test - age 17 or more, the comment above it says. Three hand-written tests already pass; none of them tries the boundary. Read domain.ek9. To play it in the IDE: Help > Lessons > Quality and reports…, or ek9 -ide -lesson edge-case-tests.

The EK9 IDE as the lesson opens

Start from

The code the lesson starts from: copy it into a file of your own and follow the steps.

domain.ek9

#!ek9
<?-
  Driving Test Centre - a candidate's age.

  REQUIREMENT: a candidate is old enough to book a driving test from age 17 (inclusive) up to 70
  (inclusive) - the Centre does not test anyone outside that range at all.
-?>
defines module quality.centre.domain

  defines constant
    MINIMUM_AGE <- 17

  defines type
    CandidateAge as Integer constrain as
      >= 17 and <= 70

  defines function

    // REQUIREMENT: eligible for a test from MINIMUM_AGE (inclusive) - the Centre's own minimum.
    isMinimumAge()
      -> age as CandidateAge
      <- rtn as Boolean: age > MINIMUM_AGE

//EOF

vehicles.ek9

#!ek9
<?-
  Driving Test Centre - vehicle categories, in their own file (same module as domain.ek9, which a
  module may span) so the Generate Edge-Case Tests… lesson can scope its whole-file run to
  domain.ek9 alone - just CandidateAge and isMinimumAge - without these four trivial classes.
-?>
defines module quality.centre.domain

  defines trait
    Vehicle
      category() as pure
        <- rtn as String?

  defines class
    Car with trait of Vehicle
      override category() as pure
        <- rtn as String: "car"
      default operator ?

    Motorcycle with trait of Vehicle
      override category() as pure
        <- rtn as String: "motorcycle"
      default operator ?

    Lorry with trait of Vehicle
      override category() as pure
        <- rtn as String: "lorry"
      default operator ?

    QuadBike with trait of Vehicle
      override category() as pure
        <- rtn as String: "quad-bike"
      default operator ?

//EOF

scoring.ek9

#!ek9
<?-
  Driving Test Centre - theory test scoring, in its own file (same module as domain.ek9) so the
  Generate Edge-Case Tests… lesson can scope its whole-file run to domain.ek9 alone, without this
  function's require/division in the same scope.
-?>
defines module quality.centre.domain

  defines function

    // REQUIREMENT: a candidate's average theory test score across every attempt made so far -
    // attempts is always at least one (a candidate who has never sat the test has no average yet).
    averageScore()
      ->
        totalMarks as Float
        attempts as Float
      <- rtn as Float?

      require attempts > 0.0
      rtn: totalMarks / attempts

//EOF

service.ek9

#!ek9
<?-
  Driving Test Centre - the booking service: a fee schedule, injected into an application, used by
  a program. Two modules (quality.centre.service depends on quality.centre.domain) for the quality
  report's Module Dependencies page; AbstractFeeSchedule/StandardFeeSchedule for its Type Hierarchy
  and DI Wiring pages.
-?>
defines module quality.centre.service

  references
    quality.centre.domain::CandidateAge

  defines constant
    JUNIOR_AGE <- 25
    SENIOR_AGE <- 60

  defines class

    BookingService
      schedule as AbstractFeeSchedule!

      quote()
        ->
          age as CandidateAge
          category as String
        <- rtn as Money: schedule.feeFor(age, category)

      default operator ?

  defines component

    AbstractFeeSchedule as abstract
      feeFor() as abstract
        ->
          age as CandidateAge
          category as String
        <- rtn as Money?

      default operator ?

    // REQUIREMENT: the test fee depends on the candidate's age band and the vehicle category -
    // three age bands across four categories, the Centre's full published fee schedule.
    StandardFeeSchedule is AbstractFeeSchedule
      override feeFor()
        ->
          age as CandidateAge
          category as String
        <- rtn as Money: 0.00#GBP

        isCar <- category == "car"
        isMotorcycle <- category == "motorcycle"
        isLorry <- category == "lorry"
        isQuadBike <- category == "quad-bike"

        isJunior <- age <= JUNIOR_AGE
        isSenior <- age <= SENIOR_AGE

        if isCar and isJunior
          rtn: 34.00#GBP
        else if isCar and isSenior
          rtn: 40.00#GBP
        else if isCar
          rtn: 38.00#GBP
        else if isMotorcycle and isJunior
          rtn: 28.00#GBP
        else if isMotorcycle and isSenior
          rtn: 32.00#GBP
        else if isMotorcycle
          rtn: 29.00#GBP
        else if isLorry and isJunior
          rtn: 60.00#GBP
        else if isLorry and isSenior
          rtn: 65.00#GBP
        else if isLorry
          rtn: 62.00#GBP
        else if isQuadBike and isJunior
          rtn: 24.00#GBP
        else if isQuadBike and isSenior
          rtn: 27.00#GBP
        else if isQuadBike
          rtn: 26.00#GBP

      default operator ?

  defines application

    CentreApp
      register StandardFeeSchedule() as AbstractFeeSchedule

  defines program

    QuoteFee() with application of CentreApp
      stdout <- Stdout()
      service <- BookingService()
      candidate <- CandidateAge(30)
      fee <- service.quote(candidate, "car")
      stdout.println(`Fee: ${fee}`)

//EOF

dev/tests.ek9

#!ek9
<?-
  Driving Test Centre - a FEW hand-written tests, deliberately leaving real gaps: isMinimumAge is
  only tried at one interior age (never the boundary, where the off-by-one lives), averageScore is
  only tried with ordinary marks (never the require/division edge), and only Car's category() is
  tried at all - Motorcycle, Lorry, QuadBike and the whole fee schedule are untested. Coverage, input
  variety and the budget bars all show what is missing.
-?>
defines module quality.centre.tests

  references
    quality.centre.domain::averageScore
    quality.centre.domain::CandidateAge
    quality.centre.domain::Car
    quality.centre.domain::isMinimumAge

  defines program

    @Test
    AdultIsEligible()
      candidate <- CandidateAge(30)
      assert isMinimumAge(candidate)

    @Test
    CarCategoryIsCar()
      car <- Car()
      assert car.category() == "car"

    @Test
    AverageOfTwoAttempts()
      assert averageScore(150.0, 2.0) == 75.0

//EOF

Step 1: Generate Edge-Case Tests

Choose Code > Generate > Edge-Case Tests (whole file)…

The EK9 IDE once this is done: Choose Code > Generate > Edge-Case Tests (whole file)…

Type 42 into Seed, so the result is the same every time, and quality.centre.tests into Test module, so the generated tests join dev/tests.ek9's own module rather than the service. Choose Generate.

The EK9 IDE just before choosing Generate: Type 42 into Seed, so the result is the same every time, and quality.centre.tests into Test module, so the generated tests join dev/tests.ek9's own module rather than the service

Step 2: Apply, and the scaffolding

The preview lists what would be written - 7 tests ready to write. Nothing is on disk yet. Choose Apply to write them for real.

The EK9 IDE just before choosing Apply: The preview lists what would be written - 7 tests ready to write

Choose Open generated file.

The EK9 IDE just before choosing Open generated file: Choose Open generated file

The file opens with a SCAFFOLDING header: the generator wrote the calls and the edge values, but did not decide what the right answer is. Every //AI: line marks one expectation to set FROM THE REQUIREMENT, never from what the code currently returns - running these and keeping whichever assertion happens to pass would cement today's behaviour, bugs included, forever.

The EK9 IDE once this is done: The file opens with a SCAFFOLDING header

Step 3: Run them

Run > Run Tests (⌘T) runs all ten - the three hand-written and the seven just generated. Choose Run Tests.

The EK9 IDE once this is done: Run > Run Tests (⌘T) runs all ten - the three hand-written and the seven just generated

Two go red, for two different reasons. testFuzz_isMinimumAge_CandidateAge_0 called isMinimumAge with no age at all - an unset CandidateAge() - and the generator's naive guess expected a SET answer: a wrong guess, nothing to do with the requirement. testFuzz_isMinimumAge_CandidateAge17_1 called it with age 17 exactly, the Centre's own minimum, and expected true - and that one is real: the code reads age > MINIMUM_AGE, which is false at exactly 17.

The EK9 IDE once this is done: Two go red, for two different reasons

Step 4: Decide each one

The requirement says nothing about a candidate with no age at all, so there is no reason to expect a set answer here. Select the live assert line for testFuzz_isMinimumAge_CandidateAge_0 and type the flip its own //AI: line offers: assert not isMinimumAge(age: CandidateAge())?. Saves when typed.

The EK9 IDE once this is done: The requirement says nothing about a candidate with no age at all, so there is no reason to expect a set answer here

Open domain.ek9.

The EK9 IDE once this is done: Open domain.ek9

The requirement says 17 IS eligible, inclusive - that one is the code's own off-by-one, not the test's. Select isMinimumAge's return line and type it with >= in place of >: <- rtn as Boolean: age >= MINIMUM_AGE. Saves when typed.

The EK9 IDE once this is done: The requirement says 17 IS eligible, inclusive - that one is the code's own off-by-one, not the test's

Step 5: All green, and the report again

Run > Run Tests (⌘T) again - choose Run Tests.

The EK9 IDE once this is done: Run > Run Tests (⌘T) again - choose Run Tests

Run > Quality Report (Shift+Cmd+R) writes the report again, now that isMinimumAge is tried at its own boundary. Choose Quality Report.

The EK9 IDE once this is done: Run > Quality Report (Shift+Cmd+R) writes the report again, now that isMinimumAge is tried at its own boundary

On the new index.html, isMinimumAge's own Input Variety rose - the boundary age is driven and asserted now, not just one interior value - and Verification's asserted ring rose with it: the same lines run as before, but more of what they could see is now actually checked.

The EK9 IDE once this is done: On the new index.html, isMinimumAge's own Input Variety rose - the boundary age is driven and asserted now, not just one interior value - and Verification's asserted ring rose with it

The result

That is Generate Edge-Case Tests: it writes the calls and the edge values, and leaves every expectation marked //AI: for a person or an AI to set from the REQUIREMENT. One flip and one real code fix later, all ten pass - and the report's Input Variety and Verification both rise, because isMinimumAge is now tried at its own boundary, not just one interior age. Keeping whichever assertion happened to pass first would have cemented the off-by-one as correct, forever. AI assistants reach the same generator through ek9_generate_edge_case_tests.

Where this lesson comes from