Thursday, May 21, 2020

Integration Testing and TestContainers

I must be behind the times. TestContainers is a library ported to many popular languages and frameworks that assists in starting up Docker containers during your test runtime. It initially was pushed to Github in 2015 and I am only NOW discovering it. Oh the troubles and headaches this would have saved me.

For years and years, I have struggled to find the correct approach to writing code and building tests in the correct balance. It was always one extreme or the other. I would write enormous amounts of code without any unit tests. I mean, come on. The project is due very soon. Who has time to write tests? Then you get the other extreme: Test-driven development. It is like being in school all over again where you show your work in Math class. How can we forget my favorite situation: 50% of your unit tests do not consistently pass. These are just mounting headaches and problems.

How do we act like perfect programming angels where you check off ALL the boxes:
  • Continuous integration where all tests run
  • Tests that pass every time
  • Integration tests that ensure connectivity to other component
  • Tests that do not depend on external databases being in the perfect state
  • 367% code coverage (you know they would ask for it if they could)
  • Shift Left test concepts
    • Involving QA earlier
    • Getting developers to assist in the test phase
  • Delivering the code at a fast pace with adequate tests to prove your work
  • Make the bosses happy
TestContainers is the metaphorical programming angel sent from binary heaven. When used properly, TestContainers allows you to build integration tests using Docker in your test runtime. Let's get setup!

Setup your environment

Make sure you have Docker installed on your development machine: Docker Desktop 

Import TestContainers in your language of choice. Check HERE for support of your language. Other languages supported are Java, Python, Golang, Node, etc. Here is the link to the Scala repo: TestContainers-Scala. For my specific use case, I'll be using ScalaTest so I'll be importing the module to run TestContainers from ScalaTest.

Build your First Test

Now, let's create our first test! I am going to use Redis because Redis solves all of the world's problems. FACT!


class TestContainersExample1 extends FlatSpec with Matchers with BeforeAndAfterAll {
private val container = GenericContainer("redis:5.0.8-alpine",
waitStrategy = Wait.forListeningPort()
)
container.start()
override def afterAll(): Unit = {
super.afterAll()
container.stop()
}
"TestContainers" should "start a Docker container" in {
val cmd = container.execInContainer("/usr/local/bin/redis-cli", "PING")
val output = cmd.getStdout
val expected = "PONG"
output.contains(expected) shouldBe true
}
}

As you can see from our test, it simply starts the Redis Docker container, calls PING, and asserts that PONG is replied back from the Redis process running in the Docker container.

Build a Test Harness

Now, setting up your test fixtures from here forward will be a major contributor to the success of this pattern. Remember our goals: shift left, deliver on time, pass tests, coverage, etc. Instead of compiling your solution, deploying to your Dev/QA environment, and telling QA to find the issues; bring the testing effort back to your local machine. Think of the applicable use cases and states of your service and put them into the test fixtures using TestContainers. I like to call it "creating a test harness". Create an interface that is easily implemented to serve as your way to create strong integration tests for your service.

trait TestContainersApiTestHarness extends FlatSpec with Matchers with BeforeAndAfterAll {
private val REDIS_PORT = 6379
private val container = GenericContainer("redis:5.0.8-alpine",
exposedPorts = Seq(REDIS_PORT),
waitStrategy = Wait.forListeningPort()
)
container.start()
private val mappedRedisExternalPort = container.mappedPort(REDIS_PORT)
private val redisClient = new RedisClient("localhost", mappedRedisExternalPort)
private val dao = new RedisTestContainersDao(redisClient)
private val api = new TestContainersApi(dao)
override def afterAll(): Unit = {
super.afterAll()
container.stop()
}
/**
* Removes all entries from the K/V (Redis) store
*/
def purgeAllData(): Unit = redisClient.doWithJedis(_.flushAll())
/**
* Import a line-delimited file with entries defined in the pattern (key=value)
* @param resource Path to resource on the classpath
*/
def importFile(resource: String): Unit = {
val res = getClass.getResourceAsStream(resource)
try {
val lines = scala.io.Source.fromInputStream(res).getLines()
val map = lines.map { l =>
val Array(k, v) = l.split('=')
k -> v
}.toMap
map.foreach { case (k, v) => redisClient.doWithJedis(_.set(k, v)) }
} finally res.close()
}
/**
* Import the specified key and value to the K/V (Redis) store
* @param key Key of the entry
* @param valueForKey Value of the entry
*/
def importKey(key: String, valueForKey: String): Unit = redisClient.doWithJedis(_.set(key, valueForKey))
/**
* Call the API to retrieve data by the specified dataId
* @param dataId dataId of the K/V entry
* @return Right(Some(VALUE)), if the value exists and is valid; Right(NONE), if no entry exists; LEFT(Throwable) when a connection error occurs or the entry is not valid
*/
def getDataId(dataId: String): Either[Throwable, Option[String]] = api.getDataByDataId(dataId)
}

Now we have create a concise interface on how to perform integration tests on this service. There is a clear pattern: purgeAllData to empty the database if necessary, import flat files or entries, call getDataId to retrieve data, and assert on the response.

Make a Test Harness Implementation

class TestContainersExample2 extends TestContainersApiTestHarness {
override def beforeAll(): Unit = {
super.beforeAll()
importFile("/etc/data1.txt")
}
"TestContainersEx2" should "return NONE when the data does not exist" in {
val result = getDataId("keyThatIsBad")
result shouldBe Right(None)
}
it should "return a good result" in {
val result = getDataId("key2")
result shouldBe Right(Some("ABC123456789"))
}
it should "fail when length < 10" in {
val key = "key1"
val result = getDataId(key)
result shouldBe Left(InvalidLengthException(key))
}
it should "fail when does not contain ABC" in {
val key = "key3"
val result = getDataId(key)
result shouldBe Left(InvalidContentException(key))
}
it should "throw an exception when connection fails" in {
val redisClient = new RedisClient("localhost", 44444)
val dao = new RedisTestContainersDao(redisClient)
val api = new TestContainersApi(dao)
val result = api.getDataByDataId("key1")
result.isLeft shouldBe true
val ex = result.left.get
ex.isInstanceOf[JedisException] shouldBe true
}
}
In the example posted above we have used the test harness to assert every possible behavior of the API/service abstraction. At this point, I have 99% confidence that everything I built will work perfectly when I move this to the QA and PRODUCTION environments. At worst, I will have some configuration issues. These tests will carry on with the service for the life of the codebase. Furthermore, the usage of the test harness is SO basic that developers of ALL levels of experience should be able to expand your test cases with little guidance.

You have now finished a POC of using TestContainers. In doing so, you have shifted left, built a solid and reliable test base that passes every time, and will have incredible levels of confidence that your service will operate appropriately when moved through QA and Production environments. I recently used this paradigm on a mission critical project at work. Not only were we ahead of schedule every single time, but we have YET to receive a single solitary bug report. Furthermore, we have received very minimal feedback from QA for any major changes. The bosses are happy because we pushed to PROD early and have an enormous test base that runs against multiple integrated components. We have absolute confidence in every build we push out.

Pull my Github repo hosting this blog post HERE and see for yourself. Join TestContainers with no regrets. Watch the reliability of your service soar.

Wednesday, May 13, 2020

Me

My name is Daniel Natic.

I have been writing software for nearly twenty years. I started hobby programming in VB6 back in the day. This evolved into writing WinForms applications in C# and custom web sites with ASP.net, SQL server, and Microsoft blah-blah-blah.

The most dramatic change in my programming life came when I changed employers and switched to Scala. I found my true passion and dove headfirst into functional programming and building distributed, highly scalable APIs. I was introduced to AWS, caching strategies, multi-data center systems, NoSQL databases, Docker, Kubernetes, and so much more.

I started this blog to share my experiences with fellow developers. I hope my readers learn from my posts and look forward to learning from them in discussions.

This blog will be primarily tailored to Scala (JVM) concepts, but is very frequently applicable to any language of choice.