Language: Scala
TestTool: Scalatest
How did we get here?
When systems become reasonably complex, tests must manage cumbersome amounts of data. A test case that may test a small bit of functionality may start to require large amounts of domain knowledge about the system being tested. This is often done through the mock data used to set up the test. Maintenance of this data becomes cumbersome, monotonous and can feel Sisyphean. To solve these problems we created “someObject”, a modular system that allows us to maintain data in only one location while providing the flexibility to create specific data for our tests.
Let’s Do A Code Time!™
To start this post, we’re going to build a system without the “someObject” test structure to provide context for its use. (To skip to the “someObject” structure, jump to here!). Suppose we are building a service that reports on advertising campaigns. We may create a class that describes an advertising campaign and call it `Campaign`.
case class Campaign(id: String, name: String, client: String, startDate: UTCDate, endDate:UTCDate, deleted: Boolean)
Now we are going to store this campaign in a database, and we need to write some integration tests to make sure this operation is performed properly. A test that confirms a campaign is stored properly might look like this:
it should "properly store a single campaign" in { Given("we have a proper campaign") val campaignToStore = Campaign( id = "someId", name = "someName", client = "someClient", startDate = UTCDate(1955, 10, 6), endDate = UTCDate(1956, 10, 6), deleted = false) And("we store this campaign in the database") database.storeCampaign() When("we fetch the given campaign") val fetchedCampaign:Campaign = database.fetchCampaign() Then("All the campaign values were stored properly") fetchedCampaign.id shouldBe campaignToStore.id fetchedCampaign.name shouldBe campaignToStore.name fetchedCampaign.client shouldBe campaignToStore.client fetchedCampaign.startDate shouldBe campaignToStore.startDate fetchedCampaign.endDate shouldBe campaignToStore.endDate fetchedCampaign.deleted shouldBe campaignToStore.deleted }
Add some functionality:
Now we add the ability to update some values for this campaign in the database, and we need to test this new piece of functionality. That test might look something like this:
it should "properly update a single campaign" in { Given("we have a proper campaign") val campaignToStore = Campaign( id = "someId", name = "someName", client = "someClient", startDate = UTCDate(1955, 10, 6), endDate = UTCDate(1956, 10, 6), deleted = false) And("some update parameters for our campaign") val updateParameters = UpdateParameters(name = Some("someNewName")) And("we store this campaign in the database") database.storeCampaign(campaignToStore) When("we update this campaign") database.updateCampaign("someId", updateParameters) val fetchedCampaign:Campaign = database.fetchCampaign("someId") Then("All the campaign values were stored properly") //Unchanged values fetchedCampaign.id shouldBe campaignToStore.id fetchedCampaign.client shouldBe campaignToStore.client fetchedCampaign.startDate shouldBe campaignToStore.startDate fetchedCampaign.endDate shouldBe campaignToStore.endDate fetchedCampaign.deleted shouldBe campaignToStore.deleted //Changed values fetchedCampaign.name shouldBe updateParameters.name }
But here we see the duplication of test boilerplate code in `campaignToStore`. We don’t want to have to copy over `campaignToStore` into every test, so we might want to abstract that out to be used all over the suite.
object MyCampaignTestObjects { val campaignToStore = Campaign( id = "someId", name = "someName", client = "someClient", startDate = UTCDate(1955, 10, 6), endDate = UTCDate(1956, 10, 6), deleted = false) }
Now we can use the same data in every test!
Add a Test that requires unique data:
Suppose we now write a function to fetch all the campaigns that are stored in the database. We might need a test that involves uniqueness in the data we store, such as the following example:
it should "properly fetch all stored campaigns" in { Given("we store several unique campaigns") val anotherCampaignToStore = Campaign( id = "someSecondId", name = "someSecondName", client = "someSecondClient", startDate = UTCDate(1955, 10, 6), endDate = UTCDate(1956, 10, 6), deleted = false) database.storeCampaign(campaignToStore) database.storeCampaign(anotherCampaignToStore) When("we fetch all campaigns") val allCampaigns = database.fetchAllCampaigns() Then("all campaigns are returned") allCampaigns shouldBe List(campaignToStore, anotherCampaignToStore) }
BlogTestWithUniqueTestData.scala
In the example, we re-used the campaign we abstracted out earlier for conciseness, but this makes this test unclear that `anotherCampaignToStore` is unique. What if someone else comes in and changes `campaignToStore` and it happens to match data from `anotherCampaignToStore`? This test would then become flakey and nobody likes flakey tests. We might decide to just make all data used in this test local to this test, but then we will need to maintain the test data in both this test, and `MyCampaignTestObjects`.
Add Some Arbitrary Data Constraints:
Suppose now that there is a new design constraint on how campaigns can be stored in the database. Now all client names must be lowercased in all campaigns:
object MyCampaignTestObjects { val campaignToStore = Campaign( id = "someId", name = "someName", //We change the client name to match our new requirement client = "some_client", startDate = UTCDate(1955, 10, 6), endDate = UTCDate(1956, 10, 6), deleted = false) }
Now we start to see the issue with maintaining test data across the whole suite that we’ve been constructing. We need to find every mock campaign that is used in our suite and ensure that its client field data is lowercased. Realistically, many of our tests, (specifically in this example, the `fetchAllCampaigns` test) don’t care about the client field of their campaign, and so we shouldn’t need to care about the client field value while setting up our mock test data. Because this example is small, it’s not cumbersome to directly update the value to satisfy the new constraint. Now let us Imagine a large set of suites, each containing hundreds of unique test cases. Suddenly this single suite requires a large amount of work to refactor one field across each test case. Nobody wants to do that monotonous maintenance. To address this, our team adopted the “someObject” structure to minimize this data maintenance within our tests.
someObject Test Structure:
When designing this test structure, we wanted to make our test data extendable for use anywhere it is needed. We used Scala’s `trait` to mix in necessary functions to provide test objects to the objects inside our tests, such as the `MyCampaignTestObjects` object above:
trait CampaignTestObjects { def someCampaign(id: String = "someId", name: String = "someName", client: String = "some_client", startDate: UTCDate = UTCDate(1955, 10, 6), endDate: UTCDate = UTCDate(1956, 10, 6), deleted: Boolean = false): Campaign = Campaign( id = id, name = name, client = client, startDate = startDate, endDate = endDate, deleted = deleted) }
object MyCampaignTestObjects extends CampaignTestObjects { //Any other setup methods for test data }
Now we can revisit our `fetchAllCampaigns` test example.
it should "properly fetch all stored campaigns" in { Given("we store several unique campaigns") val campaignToStore = someCampaign(id = "someId") val anotherCampaignToStore = someCampaign(id = "someNewId") database.storeCampaign(campaignToStore) database.storeCampaign(anotherCampaignToStore) When("we fetch all campaigns") val allCampaigns = database.fetchAllCampaigns() Then("all campaigns are returned") allCampaigns shouldBe List(campaignToStore, anotherCampaignToStore) }
Inside this test, we’ve set up two unique campaigns, by calling the `someCampaign` method from our test data trait. This populates the returned campaign with dummy data that we don’t care about. All we need out of this method is “some campaign” with “some data”. Now, instead of obscuring the intent of the test case by setting up cumbersome, overly-expressive mock data, we can simply override the implicitly available mock objects with only the necessary data. For the unique campaigns needed in the `fetchAllCampaigns` test, we only only really care about each campaign’s identifier. We don’t update the name, client, startDate, etc. because this test doesn’t care about any of that data. We only need to care that the campaigns are unique for our database. Under this test structure, when we receive the design change about the client names being lowercased, we don’t need to update our `fetchAllCampaigns`.
Another Example:
Let’s provide another example that our team encountered. Campaigns inside our database now need to also store the amount of money spent on each ad campaign. We’re adding a new column to our database, and changing the database schema.
case class Campaign(id: String, name: String, client: String, totalAdSpend: Int, startDate: UTCDate, endDate: UTCDate, deleted: Boolean)
Now, every test that has a campaign involved needs to be updated to include a new field; but under the “someObject” structure we only need to add two lines and all existing tests should be working fine again:
trait CampaignTestObjects { def someCampaign(id: String = "someId", name: String = "someName", client: String = "some_client", totalAdSpend: Int = 123456, startDate: UTCDate = UTCDate(1955, 10, 6), endDate: UTCDate = UTCDate(1956, 10, 6), deleted: Boolean = false): Campaign = Campaign( id = id, name = name, client = client, totalAdSpend = totalAdSpend, startDate = startDate, endDate = endDate, deleted = deleted) }
Behavior Driven Tests:
The purpose of the “someObject” structure is to minimize data maintenance within tests. We want to ensure that we’re disciplined about only setting data that the tests need to care about. There are cases where data might seem necessary for what we are testing, but we can abstract that data away to de-couple the test’s reliance on hard coded values. For example, suppose we have a function that returns the sum of all the `totalAdSpend` across our database.
def sumAllSpend(campaignsToSum: List[Campaign]):Int = campaignsToSum.reduce(_.totalAdSpend + _.totalAdSpend)
To test this function, we might write a test like this:
it should "properly sum all totalAdSpend" in { Given("we store several unique campaigns") val campaignToStore = someCampaign(id = "someId", totalAdSpend = 123) val anotherCampaignToStore = someCampaign(id = "someNewId", totalAdSpend = 456) database.storeCampaign(campaignToStore) database.storeCampaign(anotherCampaignToStore) When("we sum all ad spend") val totalTotalAdSpend = sumAllSpend(database.fetchAllCampaigns()) Then("the result is the sum of all ad spend") totalTotalAdSpend shouldBe 975 }
While this test does work, and it utilizes this “someObject” structure, it still forces data management at the test level.
`sumAllSpend` doesn’t really care about any one campaign’s `totalAdSpend` value. It only cares that we add all of the `totalAdSpend` values up correctly. We could instead write our test to assert on this behavior instead of doing the math ourselves and taking on the responsibility of managing more data.
it should "properly sum all totalAdSpend" in { Given("we store several unique campaigns") val campaignToStore = someCampaign(id = "someId") val anotherCampaignToStore = someCampaign(id = "someNewId") val allCampaignsToStore = List(campaignToStore,anotherCampaignToStore) allCampaignsToStore.foreach(campaign => database.storeCampaign(campaign)) When("we sum all ad spend") val totalTotalAdSpend = sumAllSpend(database.fetchAllCampaigns()) Then("the result is the sum of all ad spend") totalTotalAdSpend shouldBe allCampaignsToStore.reduce(_.totalAdSpend + _.totalAdSpend) }
With this test, we don’t care what campaigns sales were, we don’t care how many campaigns are stored, and we don’t care about any constant value. This test will return the sum of all campaign’s `totalAdSpend` value that we store in the database.
Conclusion:
In this introductory blog post, we explored the someObject testing structure in scala, but this concept is not intended to be language specific. Scala makes this concept easy to implement through the use of Default Parameter Values but in a future post I’ll show how it can be implemented through the Builder Pattern in a language like Java. Another unexplored “someObject” concept is the granularity of control in setting default data. This post introduces the “global” and test specific setting of default data, but doesn’t explore how to set test suite level data for our test objects, and the cases in which that might be useful. I’ll discuss that the future post as well.
Code snippets:
BlogIntro.scala
BlogUpdateFunction.scala
BlogAbstractToSuite.scala
BlogTestWithUniqueTestData.scala
BlogNewConstraint.scala
BlogBasicSomeObject.scala
BlogPostSchemaChange.scala
BlogPostNonBehaviorTest.scala
BlogPostBehaviorTest.scala