Scala - Behavior-driven Development (BDD) Testing
Behavior-Driven Development (BDD) is a software development process. BDD encourages collaboration between developers, testers, and business stakeholders. It aims to create a common understanding of the desired behavior of the software through the use of plain language and structured scenarios. ScalaTest is a library that supports BDD-style testing with other testing styles like TDD and acceptance testing.
Why BDD?
BDD has various advantages over traditional testing approaches. Some of these advantages are given below −
- It provides a common language for developers, testers, and business stakeholders.
- It focuses on the behavior of the software rather than its implementation.
- It helps create more understandable and maintainable tests.
- It encourages collaboration and communication among team members.
Setting Up ScalaTest for BDD
You need to add the following dependency to your build.sbt file to start using ScalaTest for BDD −
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.15" % Test
It includes ScalaTest in your project. So, you can write and run BDD-style tests.
BDD Testing with ScalaTest
In BDD, tests are written in a structured format that includes "Given", "When", and "Then" steps. So, you can define context, action, and expected outcome of a test scenario.
Example of Bank Account
Consider this simple example of testing a bank account balance update functionality.
You need to define BankAccount.scala under src/main/scala folder −
package com.example
class BankAccount(var balance: Double) {
def addToBalance(amount: Double): Unit = {
balance += amount
}
}
You need to define BankAccountSpec.scala under src/test/scala folder −
package com.example
import org.scalatest.GivenWhenThen
import org.scalatest.funspec.AnyFunSpec
class BankAccountSpec extends AnyFunSpec with GivenWhenThen {
describe("A bank account") {
it("should update the balance when money is deposited") {
Given("a bank account with a balance of $30")
val bankAccount = new BankAccount(30)
When("$40 is added to the account balance")
bankAccount.addToBalance(40)
Then("the balance should be $70")
assert(bankAccount.balance == 70)
}
}
}
Now, you should also have this dependency in your build.sbt file −
name := "MyProject"
version := "0.1"
scalaVersion := "2.13.14"
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.15" % Test
testFrameworks += new TestFramework("utest.runner.Framework")
Now, you can clean, compile and run test using these sbt commands −
sbt clean compile sbt test
The output will be −
[info] BankAccountSpec: [info] A bank account [info] - should update the balance when money is deposited [info] + Given a bank account with a balance of $30 [info] + When $40 is added to the account balance [info] + Then the balance should be $70 [info] Run completed in 365 milliseconds. [info] Total number of tests run: 1 [info] Suites: completed 1, aborted 0 [info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0 [info] All tests passed. [success] Total time: 5 s, completed 08-Aug-2024, 8:42:45pm
Example of Math Utility
This is another example for testing a simple utility function.
You need to define MathUtils.scala under src/main/scala folder −
package com.example
object MathUtils {
def double(i: Int): Int = i * 2
}
You need to define MathUtilsSpec.scala under src/test/scala folder −
package com.example
import org.scalatest.funspec.AnyFunSpec
class MathUtilsSpec extends AnyFunSpec {
describe("MathUtils::double") {
it("should handle 0 as input") {
val result = MathUtils.double(0)
assert(result == 0)
}
it("should handle positive integers") {
val result = MathUtils.double(2)
assert(result == 4)
}
it("should handle negative integers") {
val result = MathUtils.double(-2)
assert(result == -4)
}
}
}
Note that your build.sbt file should be the same as above given.
Now, you can clean, compile and run test using these sbt commands −
sbt clean compile
sbt test
The output will be −
[info] MathUtilsSpec:
[info] MathUtils::double
[info] - should handle 0 as input
[info] - should handle positive integers
[info] - should handle negative integers
[info] Run completed in 570 milliseconds.
[info] Total number of tests run: 3
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 3, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 2 s, completed 08-Aug-2024, 8:49:12pm
Advanced BDD Features in ScalaTest
Here are some advanced BDD features you can use in ScalaTest −
1. Using GivenWhenThen for Granular Specifications
You can use the GivenWhenThen trait to add more granular steps within a test. For example,
package com.example
import org.scalatest.GivenWhenThen
import org.scalatest.funspec.AnyFunSpec
class StackSpec extends AnyFunSpec with GivenWhenThen {
describe("A Stack") {
it("should pop values in last-in-first-out order") {
Given("a non-empty stack")
val stack = new Stack[Int]
stack.push(1)
stack.push(2)
When("pop is invoked on the stack")
val result = stack.pop()
Then("the most recently pushed element should be returned")
assert(result == 2)
}
it("should throw NoSuchElementException if an empty stack is popped") {
Given("an empty stack")
val emptyStack = new Stack[String]
When("pop is invoked on the stack")
Then("NoSuchElementException should be thrown")
intercept[NoSuchElementException] {
emptyStack.pop()
}
And("the stack should still be empty")
assert(emptyStack.isEmpty)
}
}
}
2. Writing Tests as Specifications
In BDD, test names are sentences that specify a bit of desired behavior. The body of the test will ensure it is working. This keeps tests focused on just one thing. So it is easier to figure out what behavior has been broken when a test fails. For example,
package com.example
import org.scalatest.funspec.AnyFunSpec
class PizzaSpec extends AnyFunSpec {
describe("A Pizza") {
it("should start with no toppings") {
val pizza = new Pizza
assert(pizza.getToppings.size == 0)
}
it("should allow addition of toppings") (pending)
it("should allow removal of toppings") (pending)
}
}
Using Different ScalaTest Traits
There are various traits that facilitate BDD style by ScalaTest. Examples are given below.
Example: Stack Testing with Different Traits
AnyWordSpec
package com.example
import org.scalatest.wordspec.AnyWordSpec
class StackWordSpec extends AnyWordSpec {
"A Stack" should {
"pop values in last-in-first-out order" in {
val stack = new Stack[Int]
stack.push(1)
stack.push(2)
assert(stack.pop() === 2)
assert(stack.pop() === 1)
}
"throw NoSuchElementException if an empty stack is popped" in {
val emptyStack = new Stack[String]
assertThrows[NoSuchElementException] {
emptyStack.pop()
}
}
}
}
AnyFeatureSpec
package com.example
import org.scalatest.featurespec.AnyFeatureSpec
class StackFeatureSpec extends AnyFeatureSpec {
Feature("Stack operations") {
Scenario("Pop values in last-in-first-out order") {
val stack = new Stack[Int]
stack.push(1)
stack.push(2)
assert(stack.pop() === 2)
assert(stack.pop() === 1)
}
Scenario("Throw NoSuchElementException if an empty stack is popped") {
val emptyStack = new Stack[String]
assertThrows[NoSuchElementException] {
emptyStack.pop()
}
}
}
}
Integrating BDD with SBT
The below are the steps to integrate BDD with SBT (Scala build tool) −
1. Creating Project Structure
A standard sbt project structure for BDD testing with ScalaTest would look like this structure −
build.sbt
project/
build.properties
src/
main/
scala/
test/
scala/
target/
2. build.sbt Configuration
Your build.sbt file should include the ScalaTest dependency as shown earlier with other necessary settings.
name := "ScalaBDDSample"
version := "0.1"
scalaVersion := "2.13.14"
libraryDependencies ++= Seq(
"org.scalatest" %% "scalatest" % "3.2.15" % Test
)
3. Sample Test Files
Place your test files in the src/test/scala/ directory. For example, if you're testing a BankAccount class, then you need to create a BankAccountSpec.scala file in the appropriate directory. For example,
package com.example
import org.scalatest.GivenWhenThen
import org.scalatest.funspec.AnyFunSpec
class BankAccountSpec extends AnyFunSpec with GivenWhenThen {
describe("A bank account") {
it("should update the balance when money is deposited") {
Given("a bank account with a balance of $30")
val bankAccount = new BankAccount(30)
When("$40 is added to the account balance")
bankAccount.addToBalance(40)
Then("the balance should be $70")
assert(bankAccount.balance == 70)
}
}
}