Unit Testing
Testing your work.
Table of contents
- What is Testing?
- JUnit
- AssertJ
- Example Test File
- Running Tests in IntelliJ
- Tests for This Assignment
- The Debugger
What is Testing?
We’ve probably all had this experience before: spending several hours writing some code, finally getting everything to compile, and then running the program; only to realize moments later that nothing works correctly, and spending another several hours disabling parts of the program to figure out what’s wrong. As it turns out, it takes a long time to figure out what’s wrong in a program, especially if we need to sequentially disable different components or write new mini-programs to check what our code is doing.
Instead of manually going through this process every time, it would be much easier to automate it—we’ll check our code by using more code! When we refer to testing, we’re referring to this process: we’re testing the correctness of our code.
You’ll see testing used extensively throughout both industry and the assignments in this course: all our assignments include some test code for the classes you’ll be implementing, our grading will similarly be automated using programmatic tests.
In particular, our tests focus on checking individual units of your code—usually individual instances of the class you write—to ensure that they behave as expected in each given scenario; this style of testing is called unit testing. Usually, each unit test follows a simple process:
- Arrange: Instantiate the unit under test and set up some scenario (e.g., adding 3 items into a list).
- Act: Invoke some method to test (e.g.,
remove(5)
). - Assert: Check that the unit behaved correctly. This may involve checking its return value, or examining other state in the object (sometimes by calling other methods), or making sure the correct exception was thrown. In most testing framework libraries, the checking is done by calling an assertion method that compares some expected value to the actual value produced. In our assignments, for example, you might see code like
assertThat(returnValue).isEqualTo(15)
.
In most unit testing frameworks (including the one we use in our assignments), each test is contained within a single method, and any incorrect assertion will cause, the assertion method to throw an exception to indicate that the test failed. On the other hand, test methods that return normally indicate that the test passed.
A couple final notes and definitions:
- Test case
- A particular scenario to test; essentially corresponds to the “arrange” and “act” steps above (although it wouldn’t be incorrect to include the “assert” step, since a program should only ever have a single correct behavior for a given scenario).
- Test suite
- A collection of test cases. Usually, we collect all tests for a particular class in another class, so we can refer to that test class as the “test suite.”
JUnit
JUnit is the unit testing framework we use in this class, and it’s also the most commonly used testing library for Java. Note that we’re using JUnit 5, which differs slightly from earlier versions of JUnit. Most package names and some method names/signatures/behavior have changed; feel free to check out the JUnit 5 documentation for more details.
As described above, we write JUnit tests by simply adding test methods to a regular Java class. JUnit test methods must satisfy a few requirements:
- They must have JUnit’s
@Test
annotation to that JUnit should treat them as tests. - They must have a
void
return type. - They usually shouldn’t have any parameters. (There are some JUnit extensions that allow you to add parameters, e.g., to use a single method to define multiple test cases based on the parameter value, but you won’t need to use these for our assignments.)
- They must not be private. By convention and for simplicity, JUnit 5 tests are usually written with default visibility (often called package-private; with no
public
/private
/protected
access modifier).
What are annotations?
Annotations are a form of metadata that you can attach to declarations, types, and certain expressions.
Annotations will have no direct effect on what the program does when run. Rather, they’re used to do one of the following:
- Provide information to the Java compiler
- Provide compile-time information to certain tools and libraries
- Attach extra information that can (sometimes) be inspected during runtime when doing meta-programming related things.
The @Test
annotation has no special meaning to the Java compiler—it’s used primarily by the JUnit library.
Another annotation you might have encountered already is the @Override
annotation. When you mark a method with this annotation, you’re telling Java that method is supposed to have overridden some other method in a super class. If the annotated method isn’t overriding anything (perhaps you made a typo?) the Java compiler will refuse to compile your code.
Basically, once everything is compiled, the @Override
annotation has zero impact on your code. It exists mainly to help the compiler catch mistakes you might have made.
AssertJ
Although JUnit provides multiple assertion methods of the form assertEquals(expected, actual)
, we use a separate assertion library called AssertJ, which provides additional assertion methods that automatically generate useful error messages for failing assertions.
Example Test File
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package mypackage;
import edu.washington.cse373.BaseTest;
import org.junit.jupiter.api.Test; // Import @Test
import java.util.ArrayList;
import java.util.List;
public class SampleTests extends BaseTest { // Extends `BaseTest`
@Test // Apply @Test
void remove_afterAddFew_returnsCorrectItem() { // Naming convention
// Set up the unit to test.
List<Integer> list = new ArrayList<>();
list.add(3);
list.add(7);
list.add(9);
// Execute the method on the unit under test.
Integer output = list.remove(2);
// Run an assertion method.
assertThat(output).isEqualTo(9); // AssertJ assertion
}
@Test
void size_afterAddMany_returnsCorrectSize() {
List<Integer> list = new ArrayList<>();
int amount = 5134;
for (int i = 0; i < amount; i++) {
list.add(i);
}
int size = list.size();
assertThat(size).isEqualTo(amount);
assertThat(list).hasSize(amount); // Specialized assertion
}
}
- Line 4
- Here, we import JUnit’s test annotation.
- Line 9
- All of our provided tests extend
BaseTest
, from our rootsrc
module.BaseTest
implementsWithAssertions
from AssertJ, providing all our test classes with the defaultassertThat
methods without need for imports. - Line 11
- Notice that our test naming convention roughly corresponds to the 3 steps in unit testing: we have 3 parts separated by underscores, where the first and last describe the “act” and “assert” steps, while the middle describes the “arrange” step. Sometimes, we also break up the middle segment into multiple underscore-separated parts to make long names easier to read.
(Note that underscores in method names are allowed in test files, but will trigger Checkstyle errors in main source files.) - Line 21
- This syntax may seem a little unusual.
assertThat(output).isEqualTo(9);
The
assertThat
call creates an object that storesoutput
and provides many assertion methods.
TheisEqualTo
assertion method is the one you’ll see most often, and essentially just checks thatoutput.equals(9)
is true. - Line 34
- AssertJ also provides specialized versions of
assertThat
with additional for particular types—in this case, aList
. This assert is equivalent to the one above it, but also provides extra feedback if it fails: the error message will include not only the expected and actual sizes, but also the contents of the list (which will be truncated if the list is too large).
AssertJ similarly allows us to add our own assertThat
implementations for custom data types; future assignments will include assertion classes for these in their test
modules.
- Tip
- Since all assertion methods in AssertJ are instance methods on some “assert object,” you can check out all available assertion methods for any given type by using IntelliJ’s code completion feature: in a test method, typing
assertThat(myVariable).
should be sufficient to get IntelliJ to suggest all assertion methods that AssertJ has for values with the same type asmyVariable
.
Running Tests in IntelliJ
Now, lets try running some tests! Open the AssertJIntro
test class in the root test
module of our project. This file contains a few basic assertions showing off some capabilities of AssertJ. Notice that the file icon has a red-green diamond, indicating that IntelliJ recognizes it as a JUnit test suite. (The file icon may vary if you have a non-default IntelliJ theme applied.)
Since JUnit is such a popular testing framework, IntelliJ integrates support for running tests directly from its UI. You can run the unit tests by following the same procedure as we did for running code: click on the play button by the AssertJIntro
class, and choose Run ‘AssertJIntro’. IntelliJ will compile your code and invoke JUnit, which will then scan through the test class and run all methods annotated with @Test
. JUnit will report the test results back to IntelliJ in real time, which should get displayed in a tool window like this:
- Note
- By default, IntelliJ may hide passing tests. You can change this by finding and clicking the
Show Passed
button (with the checkmark icon) in the top left of the tool window.
Also try running an individual test method (e.g., simpleExamples
) by tapping the green play icon next to the method’s declaration (line 9).
Tests for This Assignment
For this assignment, we’ve provided all the tests you’ll be graded on; find them in the cse143review.test
module. There’s one test class for each class in problems
, each filled with many unit tests for the methods you’ll be implementing.
You can try running these now, but they’ll all fail unless you’ve already started on the assignment. Instead, our main goal here is to introduce one last thing:
The Debugger
IntelliJ includes a built-in Java debugger, and we’ve enhanced it by installing the Java Visualizer plugin. We can run the program in debug mode by choosing Debug ...
instead of Run ..
when you click on a play icon. Like jGRASP, the IntelliJ debugger runs your program exactly like it would when running it normally, but it allows you to pause execution on breakpoints.
Let’s try this now: open datastructures.LinkedIntList
and place a breakpoint on line 49 (the end of the array constructor). Afterwards, open LinkedIntListProblemsTests
and start the debugger for reverse3_returnsCorrectList
.
When the debugger stops at the specified breakpoint, the IntelliJ will bring up the Debug tool window. Here, you can see the paused state of the program. At the top of the Debug tool window are small icons for controlling the debugger, including the two most common operations to Step over and Step into the next statement. This is similar to how the jGRASP debugger works.
We can visualize the debugger output by tapping on the Java Visualizer button at the top of the Debug tool window. This will draw a box-and-pointer diagram representing the paused program state. You can step through the execution of the program, or use the green continue icon on the left side of the Debug tool window to resume normal execution.
Now, we have all the tools we need to work on the main assignment.