Introduction

Our Hero model is pretty solid now. Let’s add some tests to ensure it behaves as expected.

MUnit

We’ll use MUnit as our testing library. Let’s add it to our Dependencies.scala file:

import sbt.*

object Dependencies {
  private object Version {
    val iron  = "3.0.0"
    val munit = "1.1.1"
  }

  lazy val iron: ModuleID = "io.github.iltotore" %% "iron" % Version.iron

  lazy val test: Seq[ModuleID] = {
    Seq("org.scalameta" %% "munit" % Version.munit)
      .map(_ % "test")
  }
}

And in the build.sbt file:

...
lazy val dependencies = {
  Dependencies.test :+
    Dependencies.iron
}
...

First tests

Create a new file test/scala/hero/model/HeroModelSpec.scala with the following code:

package dev.kamgy
package hero.model

import HeroModel.*

class HeroModelSpec extends munit.FunSuite {

  test("should not create HeroName with 4 alphanumeric characters") {
    assert(HeroName.either("Hero").isLeft)
  }

  test("should create HeroName with 5 alphanumeric characters") {
    assertEquals(HeroName.either("Hero1"), Right(HeroName("Hero1")))
  }

  test("should not create HeroName with special characters") {
    assert(HeroName.either("Hero!").isLeft)
  }

  test("should not create HeroLevel with negative value") {
    assert(HeroLevel.either(-1).isLeft)
  }

  test("should not create HeroLevel with 0 value") {
    assert(HeroLevel.either(0).isLeft)
  }

  test("should create HeroLevel with positive value") {
    assertEquals(HeroLevel.either(1), Right(HeroLevel(1)))
  }
}

You can run the tests from IntelliJ: Run tests or from the command line:

sbt test

They should all pass: Green tests

Nice! But can we do better? For example, we’ve only tested a valid HeroName with 5 characters. What about 6? Or 20?

To handle that, we can use property-based testing — a technique where we define general properties that our code should satisfy, and then use automatically generated random input data for testing.

This helps uncover edge cases and ensures our model behaves correctly across a broader input space than manually written test cases would cover.

MUnit + ScalaCheck

We’ll use ScalaCheck with MUnit. Add it to your Dependencies.scala file:

import sbt.*

object Dependencies {
  private object Version {
    val iron            = "3.0.0"
    val munit           = "1.1.1"
    val munitScalaCheck = "1.1.0"
  }

  lazy val iron: ModuleID = "io.github.iltotore" %% "iron" % Version.iron

  lazy val test: Seq[ModuleID] = {
    Seq(
      "org.scalameta" %% "munit" % Version.munit,
      "org.scalameta" %% "munit-scalacheck" % Version.munitScalaCheck
    ).map(_ % "test")
  }
}

Since we already use Dependencies.test in build.sbt, there’s nothing more to change there.

Generators

Let’s create a base for generators in test/scala/Generators.scala:

package dev.kamgy

import org.scalacheck.Gen

object Generators {
  def alphaNumStrings(minLength: Int, maxLength: Int): Gen[String] = {
    for {
      length <- Gen.choose(minLength, maxLength)
      str    <- Gen.listOfN(length, Gen.alphaNumChar).map(_.mkString)
    } yield str
  }

  def specialChars: Gen[Char] = Gen.choose(Char.MinValue, Char.MaxValue).suchThat(!_.isLetterOrDigit)
}

alphaNumStrings will create a string of alphanumeric characters with a length between minLength and maxLength.

specialChars will generate a random character that is not alphanumeric.

Then add test/scala/hero/model/HeroGenerators.scala:

package dev.kamgy
package hero

import hero.model.HeroModel.*
import org.scalacheck.*
import org.scalacheck.Gen.*

object HeroGenerators {
  def heroNameOpt(minLength: Int, maxLength: Int): Gen[Option[HeroName]] =
    Generators.alphaNumStrings(minLength, maxLength).map(HeroName.option.apply)
  def heroLevelOpt(minLevel: Int, maxLevel: Int): Gen[Option[HeroLevel]] =
    Gen.choose(minLevel, maxLevel).map(HeroLevel.option.apply)
}

heroNameOpt will try to create valid instances of HeroName. It will return Some(name) or None if the name is invalid.

Similarly, heroLevelOpt will return Some(level) or None if the level is invalid.

Property-based Hero tests

Let’s update HeroModelSpec.scala:

package dev.kamgy
package hero.model

import hero.HeroGenerators
import hero.model.HeroModel.HeroName
import munit.ScalaCheckSuite
import org.scalacheck.Prop.*

class HeroModelSpec extends ScalaCheckSuite {

  property("HeroName should be invalid with less than 5 characters") {
    forAll(HeroGenerators.heroNameOpt(0, 4)) { heroNameOpt =>
      assert(heroNameOpt.isEmpty)
    }
  }

  property("HeroName should be invalid with more than 32 characters") {
    forAll(HeroGenerators.heroNameOpt(33, 64)) { heroNameOpt =>
      assert(heroNameOpt.isEmpty)
    }
  }

  property("HeroName should be invalid if a valid-length name contains non-alphanumeric characters") {
    forAll(HeroGenerators.heroNameOpt(5, 31), Generators.specialChars) {
      case (Some(validName), specialChar) =>
        val invalidName = validName + specialChar
        assert(HeroName.option(invalidName).isEmpty)
      case _ => fail("Generator produced invalid HeroName unexpectedly")
    }
  }

  property("HeroName should be valid with 5 to 32 characters") {
    forAll(HeroGenerators.heroNameOpt(5, 32)) { heroNameOpt =>
      assert(heroNameOpt.isDefined)
    }
  }

  property("HeroLevel should be invalid below 1") {
    forAll(HeroGenerators.heroLevelOpt(-1000, 0)) { heroLevelOpt =>
      assert(heroLevelOpt.isEmpty)
    }
  }

  property("HeroLevel should be valid for positive values") {
    forAll(HeroGenerators.heroLevelOpt(1, 10000)) { heroLevelOpt =>
      assert(heroLevelOpt.isDefined)
    }
  }
}

Every time you run the tests, a variety of values will be generated for each case. You can temporarily add println(heroNameOpt) inside one of the tests to see what’s being tested. That’s the magic of property-based testing!

Summary

In the next post, we’ll add more models to our game.

You can find the complete code for this post here .