lilt: Lightweight Interactive Learning Tool

Topic 12

Testing with JUnit; Dates and Times

This week we will look at:

  • Unit testing
  • A quick introduction to dates and times in Kotlin (and Java)

Testing - general introduction

Obviously it is important to test software before releasing it, to iron out bugs. Software testing can be done in an informal, ad-hoc way, however the disadvantage of this is that the developer is likely to miss out testing crucial functionality. The robustness of the software can be enhanced by taking a more formal approach to testing, by drawing up a test plan documenting all tests and the expected and actual output, as well as performing a series of unit tests designed to test different parts of the system.

Unit testing

A unit test is designed to test one small part of the system in isolation, such as a method. Unit tests are written to test different outcomes of a method. For example, an Event class (used in the live music venue application) might have a book() method which takes the number of tickets to book as a parameter. This could have three outcomes:

  • The event is booked successfully;
  • There are insufficient tickets available;
  • The number of tickets is invalid, i.e. zero or a negative number.

We would create unit tests to test each of the three possible outcomes and check that the expected behaviour does indeed occur in each case.

Unit tests for this book() method could involve:

  • Making a valid booking and checking that true is returned;
  • Making a valid booking and checking that the amount is reduced by the expected amount;
  • Supplying an invalid amount (0 or less), checking that false is returned;
  • Supplying an invalid amount (0 or less), checking that the number of tickets does not change;
  • Supplying an amount greater than the number of tickets, and checking that false is returned;
  • Supplying an amount greater than the number of tickets and checking that the number of tickets does not change.

JUnit - A Unit Testing Framework

In Kotlin and Java, unit testing is made straightforward by the open-source unit testing framework JUnit.

There is a good tutorial at Vogella which was partly used to research these notes, though it is Java-oriented, not Kotlin.

Setting up a project for JUnit testing in Kotlin

In the Kotlin documentation is a document describing how to setup JUnit tests in Kotlin.

You need to ensure you create a Gradle project. Once you've done this you need to check that the required libraries are in your build.gradle.kts within the dependencies section. If you create a Gradle project, this should be done for you already. Check that your build.gradle.kts contains the following sections, and if not, add them.

Example

Imagine you have an Event class above. To use JUnit, we have to create a test class with a series of unit tests. You can create a test class by opening one of your classes and then selecting Code-Generate-Test...

Here is an example of a test class containing a series of unit tests that you would run with JUnit on an Event class:

Looking at this in more detail:

  • Note how we have a series of test methods. Note how each is preceded with the annotation @Test. This informs JUnit that the method coming up is a test that it should run. (This is actually Kotlin syntax, not specifically JUnit).
  • Note how each method creates an instance of the class under test (Event here), performs an operation on it (e.g. sells it) and tests whether a condition has been met with an assertion. Assertions include assertTrue() (tests whether a method returns true), assertFalse() (tests whether a method returns false), or assertEquals() (which tests whether a return value is equal to specified value.

Setup and Teardown

We can write special code to perform common operations before each test or before all tests, and corresponding code to perform common operations after each test and all tests. The former is known as setup and the latter is known as teardown.

Why is this useful? It allows us to create a common system containing a number of objects, and objects within objects, which will be used to run all tests starting from the same state. So for example we can setup a shop containing multiple products, and run each test on the shop in this state.

We can use a number of annotations to do this:

  • @BeforeEach - specifies a method to run before each test.
  • @BeforeAll - specifies a method to run before all tests.
  • @AfterEach - specifies a method to run after each test.
  • @AfterAll - specifies a method to run after all tests.

For example, we could reset the shop's properties (total takings, product list) using a @BeforeEach annotated method:

@BeforeAll

When is @BeforeAll useful? It's useful for expensive operations, which we want to run only once, before all tests are run (as opposed to each individual test). A good example would be connecting to the database. The @BeforeAll method has to be marked as static, indicating that it applies to the test class as a whole, rather than specific instances of it. In Kotlin you do this through the use of a companion object. It also means that that the method need to be declared as @JvmStatic. An example is given below; this also shows the use of @AfterAll to implement a tear-down method to close the connection.

Parameterised Tests

Often, tests differ only in the parameter we are supplying to the method. You can see this above in the tests within EventTest; most only differ by the amount of tickets we are supplying. Clearly this is rather inefficient. It would be better if we could run a single test with one or more parameters - and luckily we can, using a TestFactory. A TestFactory creates one test for each item in a list. Here we try to sell -1, 1, 2 and 101 tickets for an event with 100 tickets available. The sell() method should return false, true, true and false respectively. So we can store the test parameter and expected value in a list of Pair objects.

Note how we begin the test factory with the annotation @TestFactory and create a list of Pair objects (objects representing a pair of values) where the keys represent the number of tickets to sell, and the values the expected outcome (true or false). We map each Pair object to a DynamicTest (a test which can be generated by input data). The function dynamicTest() creates a DynamicTest object. It takes as arguments a description of the test and a lambda in which we can do the actual test.

The test data in the list of Pair objects covers these cases:

  • Selling -1 tickets. sell() should return false as -1 is an invalid quantity, and not change the remaining tickets (so this remains as 100).
  • Selling 1 ticket. sell() should return true and leave 99 remaining tickets.
  • Selling 2 tickets. sell() should return true and leave 98 remaining tickets.
  • Selling 101 tickets. sell() should return false due to attempting to sell one more than the maximum available number of tickets, and as a result not change the remaining tickets (so this remains as 100).

In the lambda we perform the test. We can access the current Pair as it is the it parameter of the lambda provided to map(). The two members of the pair can be retrieved using its first and second properties, i.e it.first and it.second here. So we try to sell the provided number of tickets (the first member of each pair) and check that the return value is equal to the expected result (the second member of each pair).

Further example: the Venue

This is an example of testing a more complex class (the Venue, which contains multiple Events). Not all possible tests are shown. As discussed below, you should test more complex classes, such as Venue, after the simpler classes (such as Event) are fully tested and working. Note how it also shows the use of the assertNotNull and assertNull assertions, which can test whether the return value is not null or null.

General strategy for unit testing

You should test each class as you write it. A good strategy is to test the simpler classes, with no dependencies on other classes, first, as then you know that those classes are fully working. For example, you would want to test the Event class and a Booking class before testing the Venue class. After testing the classes with no dependencies, you can then test the larger and more complex classes (e.g. Venue), which use those simpler classes, to see if they work.

You should test each method with non-trivial functionality, e.g. methods that search for, or update, data. Ensure you test both success conditions and error conditions for each method.

You should also ensure you test edge cases. An edge case is a value on the boundary between two outcomes. For example, if a venue has 100 tickets, you might want to test whether 100 tickets can be booked (which should work), and whether 101 tickets can be booked (which should not). Edge cases are common places to find bugs (one example is confusion between < and <=) and ensuring they are included as data in tests means that such bugs are likely to be found.

References

Lars Vogel's tutorial on JUnit - Java-oriented, but likely to be useful

Exercise 1

Clone the repo at https://github.com/nwcourses/UniversityForTesting.

This exercise allows you to think about what tests might be needed for the university system before you write them.

Ensure you consider error cases as well as success cases.

  • State a series of tests that could be done on the Student class. Include the input parameters you would use.
  • State a series of tests that could be done on the University class. Include the input parameters you would use.
Submission disabled now you have completed or the notes have been made public.

Answer to exercise 1

For Student, you could include:

  • Testing the custom setter. Test setting a valid mark at each boundary (0 and 100, as well as 1 - due to 0 being the default value of mark) and an invalid mark also at each boundary (-1 and 101). Ensure the resulting marks are as expected with an assertEquals().

  • Testing the getGrade() method. Include grades at each boundary i.e. 100, 70, 69, 60, 59, 50, 49, 40, 39. A parameterised test would be the best way to do this.

For University, you could include:

  • Testing that a student is added successfully to a list. Add the student with addStudent() and then assert that the list has length one and that the ID of the student added is what you expect.

  • Testing findStudentById(). Add a student (as above) and then search for the student with that ID. Assert that the returned student is not null and has the expected ID.

  • Testing findStudentById() with an invalid ID. Search for the student with an invalid ID. Assert that the returned student is null.

  • Testing findStudentsByCourse() returns the correct number of students (success case). Add multiple students on different courses. Choose a course to find students on, and ensure the students returned are the ones you expect (e.g. you could add two students on that course and assert that two students are returned).

  • Testing findStudentsByCourse() returns the correct students (success case, more detailed than above). Ensure that the IDs of the students returned are the IDs you expect.

  • Testing findStudentsByCourse() (error case). Repeat the above but try to find students on a course with no students studying it. Assert that the size of the list returned is 0.

Exercise 2

Now write the unit tests we have discussed above.

Dates and times

One thing we haven't looked at yet is dates and times in Kotlin. This is useful for the second assessment (not essential, but useful for better answers) so we will take a quick look now.

We use the java.time API which was, as the name suggests, developed as part of Java (introduced in Java 8). Like all Java APIs it can also be used in Kotlin. It consists of several useful classes including:

  • LocalDateTime: represents a combined date/time (a datetime). LocalDateTime is simply a datetime - it is not associated with a particular time zone, so does not represent an absolute time.

  • ZoneId: represents a particular timezone, e.g. Europe/London for London or Europe/Paris for Paris.

  • ZonedDateTime: represents a date/time in a specific timezone, e.g 9am on 8 August 1998 in London. In other words, this is an absolute time. Time is measured in seconds since midnight UTC (Coordinated Universal Time, equal to winter time in London).

  • Duration: represents the difference between two times.

  • Instant: represents a specific instant in time.

The simple program below shows various features of the java.time API. It is annotated with explanatory comments.