Testing

Robust code must be tested constantly—every time you make changes.

If changing one part of your code unexpectedly affects other code, your tests tell you immediately, as soon as you make the change, and you see right away which change broke your code. If you don’t find out immediately, changes accumulate and you don’t know which one caused the problem—you spend a lot longer tracking it down. Constant testing is essential for rapid program development.

Because testing is a crucial practice, we introduce it early and use it throughout the rest of the book. This way, you become accustomed to testing as a standard part of the programming process.

Using println() to verify code correctness is a weak approach; you must pay attention to the output every time and consciously ensure that it’s right.

To simplify your experience using this book, we created our own tiny testing system. The goal is a minimal approach that:

  1. Shows the expected result of expressions, for easier comprehension.
  2. Shows some output so you see that the program is running, even when all the tests succeed.
  3. Ingrains the concept of testing early in your practice.

Although useful, ours is not a testing system for use in the workplace. Others have worked long and hard to create such test systems.

To use our testing framework, we must first import it. The basic elements of the framework are eq (for equals) and neq (for not equals):

// Testing/TestingExample.kt import atomictest.* fun main(args: Array<String>) { val v1 = 11 val v2 = "Ontology" // Test expressions using 'eq' ("equals"): v1 eq 11 v2 eq "Ontology" // 'neq' means "not equal" v2 neq "Epistimology" // Error: Epistimology != Ontology // v2 eq "Epistimology" } /* Output: 11 Ontology Ontology */

You can find the code for the atomictest package in Appendix B: AtomicTest. We don’t intend that you understand all the code for AtomicTest.kt right now, because it uses some features that we haven’t covered yet.

To produce a clean, comfortable appearance, AtomicTest uses a Kotlin feature that you haven’t seen yet: the ability to write a function call a.function(b) in the text-like form:

a function b

This is called infix notation. Only functions defined using the infix keyword can be called this way. AtomicTest.kt defines the infix eq and neq used in TestingExample.kt:

expression eq expected expression neq expected

This system is flexible—almost anything works as a test expression. If expected is a string, then expression is converted to a string and the two strings are compared. Otherwise, expression and expected are compared directly (without converting them first). In either case, expression is displayed on the console so you see something happening when the program runs. Even when the tests succeed, you still get output showing the contents of the object on the left of eq or neq. If expression and expected are not equivalent, AtomicTest shows an error when the program runs.

The last test in TestingExample.kt intentionally fails so you see an example of failure output. If the two values are not equal, Kotlin prints the message and stops executing the program. If you uncomment the last line and run the example above, you will see, after all the successful tests:

Error: Epistimology != Ontology

That means the actual value stored in v2 is not what it is claimed to be in the “expected” expression. AtomicTest then displays the string representations for both expected and actual values.

Notice that if the eq or neq fails then all subsequent lines in the function never run; that’s because AtomicTest aborts the program’s execution.

That’s all there is to it. eq() and neq() are the basic (infix) functions defined for AtomicTest—it truly is a minimal testing system. Now you can put eq and neq expressions anywhere in your examples to produce both a test and some console output, and verify the correctness of the programs simply by running them.

In previous atoms, we displayed the output and relied on human visual inspection to catch any discrepancies (although we also have an additional mechanism in the book’s build system that verifies displayed output). That’s unreliable; even in a book where we scrutinize the code over and over, we’ve learned that visual inspection can’t be trusted to find errors. From now on we usually won’t use commented output blocks because AtomicTest will do everything for us. However, sometimes we’ll still include commented output blocks when that produces a more useful effect.

Anytime you run a program that uses AtomicTest, you automatically verify the correctness of that program. Ideally, by seeing the benefits of using testing throughout the rest of the book, you’ll become addicted to the idea of testing and will feel uncomfortable when you see code that doesn’t have tests. You will probably start feeling that code without tests is broken by definition.

Testing as Part of Programming

Testing is most effective when it’s built into your software development process. Writing tests ensures you get the results you expect. Many people advocate writing tests before writing the implementation code—to be rigorous, you first make the test fail before you write the code to make it pass. This technique, called Test Driven Development (TDD), is a way to make sure that you’re really testing what you think you are. There’s a more complete description of TDD on Wikipedia (search for “Test_driven_development”).

There’s another benefit to writing testably—it changes the way you think about and design your code. In the previous example, we could just display the results to the console. But the test mindset makes you think, “How will I test this?” When you create a function, you begin thinking that you should return something from the function, if for no other reason than to test that result. Functions that take one thing and transform it into something else tend to produce better designs, as well.

Here’s a simplified example using TDD to implement the BMI calculation from Number Types. First, we write the tests, along with an initial implementation that fails (because we haven’t yet implemented the functionality):

// Testing/TDDFail.kt package testing1 import atomictest.eq fun main(args: Array<String>) { calculateBMI(160, 68) eq "Normal weight" // calculateBMI(100, 68) eq "Underweight" // calculateBMI(200, 68) eq "Overweight" } fun calculateBMI(lbs: Int, height: Int) = "Normal weight"

Only the first test passes. The other tests fail and are commented. Next we add code to determine which weights are in which categories. However, all the tests will fail now:

// Testing/TDDStillFails.kt package testing2 fun main(args: Array<String>) { // Everything fails: // calculateBMI(160, 68) eq "Normal weight" // calculateBMI(100, 68) eq "Underweight" // calculateBMI(200, 68) eq "Overweight" } fun calculateBMI( lbs: Int, height: Int ): String { val bmi = lbs / (height * height) * 703.07 return if (bmi < 18.5) "Underweight" else if (bmi < 25) "Normal weight" else "Overweight" }

We’re using Ints instead of Doubles, producing a zero result. The tests guide us to the fix:

// Testing/TDDWorks.kt package testing3 import atomictest.eq fun main(args: Array<String>) { calculateBMI(160.0, 68.0) eq "Normal weight" calculateBMI(100.0, 68.0) eq "Underweight" calculateBMI(200.0, 68.0) eq "Overweight" } fun calculateBMI( lbs: Double, height: Double ): String { val bmi = lbs / (height * height) * 703.07 return if (bmi < 18.5) "Underweight" else if (bmi < 25) "Normal weight" else "Overweight" }

You may choose to add additional tests to ensure we have tested the boundary conditions completely.

In the exercises for this book, we include tests that your code must pass.

Previous          Next

©2018 Mindview LLC. All Rights Reserved.