Scala/Play, Part 14: Testing Scala Results - Values, Errors and Exceptions
After working through Option, Either and Try, my Scala lab could describe both successful results and failures. The next question was less about new language features: how do I verify those outcomes with tests that explain what went wrong?
Coming from Java and Spring, the testing idea is familiar. The ScalaTest syntax was not. Having AI-generated AnyFunSuite code in another project did not mean I understood it, so I went back to the smallest complete example.

The behavior I am testing
The fictional policy flow looks up a policy, parses an installment count, validates that it is positive, then builds a label. It returns Either[String, String]: a reason on the left or a label on the right. POL-001 has a base premium of 600 euros; twelve installments produce this value:
Right("POL-001: 600 EUR / 12 = 50 EUR")
The lookup starts as an Option, and parsing uses Try. The workflow explicitly converts both into Either before composing them. A missing policy or malformed count therefore becomes a returned Left. This example uses whole-euro integer division, not a production payment schedule or the separate claim-free discount calculation.
AnyFunSuite, without assuming the magic
The lab uses Scala 2.13.18, sbt 1.12.15 and a test-scoped ScalaTest dependency:
libraryDependencies +=
"org.scalatest" %% "scalatest" % "3.2.19" % Test
I retained 3.2.19 to match the existing sbt study project; this is a reproducible version choice, not a claim that it is the latest. The %% selects the Scala-cross-built artifact, and % Test keeps the dependency in the test configuration.
Under src/test/scala/learning, the file ResultFlowLessonSpec.scala starts with a minimal suite:
package learning
import org.scalatest.funsuite.AnyFunSuite
class ResultFlowLessonSpec extends AnyFunSuite {
test("a known policy and 12 installments produce a 50 EUR base-premium label") {
val actual =
ResultFlowLesson.installmentLabel("POL-001", "12")
assert(actual == Right("POL-001: 600 EUR / 12 = 50 EUR"))
}
}
AnyFunSuite is a library base class for a group of named tests. extends gives this class its testing methods. test is one of those methods—not a Scala keyword. The parentheses supply the name; the following block supplies the code registered for the runner to execute later.
There is no application main here. The Spec suffix is a naming convention, not what makes this class a suite. The assertion compares the complete result: both the Right branch and its label. This is the library's documented AnyFunSuite model, with ordinary Scala expressions inside each test.
A Left can make a test pass
Application failure and test failure answer different questions. If a policy does not exist, rejecting it is correct behavior:
test("a missing policy returns its not-found reason") {
val actual =
ResultFlowLesson.installmentLabel("POL-999", "12")
assert(actual == Left("Policy not found: POL-999"))
}
The test passes when that exact rejection is returned. I also gave malformed text, zero and negative counts separate names. They exercise different decisions: "hello" cannot be parsed, whereas "0" parses successfully but violates the positive-count rule.
When both inputs are invalid, the suite expects the policy-not-found reason first. That checks the externally visible error choice; it does not, by itself, prove whether an internal method was invoked.

A thrown exception needs a different check
The raw expression "hello".toInt throws. Unlike readInstallments("hello"), it has no surrounding application logic converting the exception into a returned result. I test that boundary separately:
test("raw text conversion throws an exception we can inspect") {
val error: NumberFormatException =
intercept[NumberFormatException] {
"hello".toInt
}
assert(error.getMessage.nonEmpty)
}
intercept is another inherited ScalaTest method. The square brackets supply a type argument: the expected exception type. The braces contain the operation to execute inside the checking boundary. A matching exception, including a subtype, is caught and returned. No exception, or an exception of the wrong type, fails the test.
The variable error therefore contains the exception object, not an integer, Left or Try. Here I inspect its nonempty message without tying the test to exact JVM diagnostic wording. ScalaTest also offers assertThrows when only the type matters; intercept is useful when inspecting the caught exception. See the assertion reference.
The tempting mistake is placing readInstallments("hello") inside that block. It returns a Left normally, so there is no escaping exception for intercept to catch. The throwing operation must also happen inside the block, not before it.
Calculate the expectation independently
I added another successful input: six installments. With a base premium of 600, the expected amount is 100.
test("six installments produce a 100 EUR base-premium label") {
// Arrange
val policyNumber = "POL-001"
val installments = "6"
// Act
val actual =
ResultFlowLesson.installmentLabel(policyNumber, installments)
// Assert
assert(actual == Right("POL-001: 600 EUR / 6 = 100 EUR"))
}
Arrange, act and assert are comments describing the same structure I can use in Java tests. This extra input catches a simple mistake that the twelve-installment case would miss: always producing a 50-euro amount.
I should not call the production method again to calculate its own expected answer. That could repeat the same bug on both sides. For this small example, an independently worked-out literal is clearer.

To check the failure report, I temporarily expected 101 instead of 100. The test runner reported the mismatch:
Right("POL-001: 600 EUR / 6 = 100 EUR")
did not equal
Right("POL-001: 600 EUR / 6 = 101 EUR")
That run had six passing tests and one failure in the selected suite. Restoring 100 made the complete run pass: nine tests across two suites. A failed test asks me to check both the implementation and the expectation; in this deliberate experiment, the expectation was wrong.
Run the tests, not just the application
sbt test
sbt "testOnly learning.ResultFlowLessonSpec"
sbt "testOnly learning.ExceptionBoundarySpec"
sbt "runMain learning.PremiumLesson"
sbt test is the practical counterpart to running tests with Maven; testOnly selects a suite. The final command runs the lesson application separately. Assertions inside its run() methods do not automatically execute under sbt test. The ScalaTest sbt guide documents suite selection.
The runnable source snapshot contains both suites and the result flow. I verified the nine tests and the full lesson runner with Java 21.0.12.1. These focused examples are not exhaustive coverage, and passing generated code is not proof of independent understanding.
That is enough testing machinery for this stage. Next come practical generic methods and typed domain errors, then asynchronous Scala—the finish line for this cursus. Play and Pekko can wait for a separate course.