1. Introduction

Developers widely use ScalaTest as the primary testing framework in Scala. ScalaTest provides a bunch of features to write efficient and elaborate tests. One such feature is the parameterized tests or table-driven tests.

Parameterized tests help write concise and reusable tests by executing the same test with different input values. This way, we can verify the code under various scenarios easily.

In this tutorial, we’ll explore how to leverage the power of parameterized tests in ScalaTest.

2. Setup

In this section, we’ll set up ScalaTest. We need to add the dependency on the ScalaTest library to the build.sbt:

libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.17" % "test"

3. The Code We Want to Test

For this tutorial, let’s define a simple method to check if a string is a palindrome or not. A palindrome is a string or a number that reads the same forwards and backward, regardless of special characters.

Let’s implement the method to check for palindromes:

def isPalindrome(str: String): Boolean = {
  val sanitizedStr = str.replaceAll("[^A-Za-z0-9]", "").toLowerCase
  sanitizedStr == sanitizedStr.reverse
}

4. Testing With Non-Parameterized Tests

Let’s also write unit tests to cover some of the use cases. For this example, we’ll use the AnyFlatSpec style from ScalaTest:

it should "check for palindrome for string 'madam'" in {
    isPalindrome("madam") shouldBe true
}
it should "check for palindrome for string 'Tacocat' with special character" in {
    isPalindrome("Taco, cat") shouldBe true
}
it should "check for palindrome for string 'Hello'" in {
    isPalindrome("Hello") shouldBe false
}

Here, we defined three different test cases to test the behavior of the function. However, a significant portion of the test is duplicated, except for the varying input values. Furthermore, it may not be immediately apparent to the reader whether there are any distinctions among the individual tests.

5. Testing With Parameterized Tests

ScalaTest provides a trait TableDrivenPropertyChecks to parameterize the tests. The core of the parameterizing of the tests is using a data structure called Table. The Table provides a convenient way to define a set of input values for the tests.

We’ll be using table-driven tests to verify different cases for the previous palindrome implementation.

5.1. Defining a Table

The Table is similar to a N*M matrix, that is defined using tuples. The initial row of the table should consist of a tuple containing strings representing the column names. Subsequently, we use the succeeding rows of tuples as the input data intended for the test.

There are a few constraints when defining the table:

  • All the data rows should have the same arity
  • The title row and the data row must be of the same arity
  • The first row of the table must be of type String
  • A table row can have a maximum of 22 fields.

Now, let’s look at defining a table to test the isPalindrome() function we implemented before:

private val palindromeTable = Table(
  ("Text", "Is Palindrome"),
  ("madam", true),
  ("Madam", true),
  ("Hello", false)
)

In this table, there are two columns, where one specifies the input text and the other denotes the expected result. A compilation error occurs when the rows have varying column counts or when there are more than 22 columns.

5.2. Using forAll()

Now that we’ve defined the table for the tests, let’s look at using this within the test. We can use the forAll() method with the previously defined table to refactor the earlier test cases:

it should "check for palindrome" in {
  forAll(palindromeTable) { (str, expectedResult) =>
    isPalindrome(str) shouldBe expectedResult
  }
}

In this approach, we passed the table as a parameter to the forAll() function. Subsequently, each row from the table, excluding the header row, is iteratively and sequentially processed, serving as a component of the test. The value from the first column is mapped to the variable str and the second column to expectedResult.

If the test passes for every row in the input table, it’s deemed successful. However, in the case where the expectation fails for at least one of the rows, the entire test is labeled as a failure. Upon detecting the first failure, ScalaTest halts processing. Additionally, it doesn’t consider any further test data. In the event of a failure, it logs the row value associated with the unsuccessful case. Notably, the rows in the Table are executed in the order they’re defined.

By using this approach, we separate the test logic from the test data, enhancing readability.

5.3. Using forEvery()

We can use the forEvery() method just like we used forAll(). The forEvery() method also executes the test for each row within the defined table. forEvery() continues to execute all the test data even after detecting the first failure, whereas forAll() terminates at the first failure. This feature is particularly helpful when we aim to view all test failures at once, especially in the CI environment.

Let’s use the forEvery() method to test the palindrome function:

it should "check for palindrome using forEvery" in {
  forEvery(palindromeTable) { (str, expectedResult) =>
    isPalindrome(str) shouldBe expectedResult
  }
}

As previously mentioned, it records and logs all the failures along with each test parameter collectively.

6. Conclusion

In this article, we explored the effective technique of parameterizing tests in ScalaTest through the use of the Table construct. This approach provides a robust and versatile method to parameterize the tests. By employing table-driven testing, we can extensively test the code without writing boilerplate code.

As always, the sample code used in this tutorial is available over on GitHub.