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:
or from the command line:
sbt test
They should all pass:
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 .