Module 3: Testing

Module Overview

In this module, you'll learn comprehensive testing strategies for Java applications. You'll understand how to write effective unit tests, implement integration tests, and apply test-driven development principles to improve code quality and reliability.

Learning Objectives

  • Implement comprehensive testing strategies using JUnit and Mockito
  • Write clear, focused test cases following the GIVEN-WHEN-THEN pattern
  • Create deterministic tests that verify functionality and catch edge cases
  • Apply test-driven development to guide implementation
  • Use mocks and stubs to isolate components for testing
  • Design integration tests that verify system behavior end-to-end

Key Topics

Introduction to Testing

Learn the importance of testing and various testing methodologies in software development.

Overview

Unit testing is a critical skill for modern software developers. This lesson will cover unit tests, a common unit test pattern, the basic anatomy of JUnit tests, and how to run JUnit tests in IntelliJ.

Note: This material was adapted from this article to simplify, clarify, and use Java examples.

What is Unit Testing?

Unit testing is the code you write to test individual pieces of your production code. Each time you write a new class in Java, you should also create a test class. The test class will contain all of the unit tests that test your new class. Unit testing is just the process of writing these unit tests and running them. It's used to ensure the quality of your working product. Imagine being a chef who never tastes your own food. Seems crazy, right? Unit testing is what you do as a developer to ensure your code is working as desired but more automated.

Other developers also use your tests to learn how to use your code. They can read and run your tests to learn how it works. A full suite of unit tests is often more useful than documentation or large quantities of comments.

Finally, your tests ensure that changes don't unintentionally break existing code through what's called regression testing. A regression defect is a bug that breaks existing working code. Regression testing is running existing, working unit tests when changing code. It is good to run all unit tests when changing code, as you may have changed how two classes interact. In fact, it's such a good practice that Amazon's build tools default to running unit tests every time you re-compile!

Three Parts of a unit test: Given, When, Then

Unit tests follow a common pattern - GIVEN, WHEN, THEN. First, set up any required data for the test case you're writing, often the inputs to the method you're testing (GIVEN). Next, call the method that you're testing (WHEN). Lastly, verify that the correct things happened (THEN). We'll follow this pattern and explore it more in the next coding activity.

What we use to write tests and where to put them

Most developers put unit tests in that tst/ top-level directory and declare test classes in the same Java package as the classes that they test. Follow this convention! (Note: There is a slightly different version for packages built via gradle -- you'll see src/main/java/com/amazon... containing the source code and src/test/java/com/amazon/... containing the unit tests.)

We're using JUnit5 for our unit tests. There are other unit testing frameworks, but JUnit is used heavily at Amazon and throughout the industry.

JUnit test anatomy and some "Happy" test cases

It's always good to start with some simple cases, often the "happy" cases where things go exactly as planned and the calling code provides typical inputs. Let's look at two tests in a test class AtaAdditionTest:

package com.amazon.ata.unittesting.prework.beginningunittesting;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class AtaAdditionTest {
    @Test
    public void add_singleInteger_returnsTheInteger() {
        // GIVEN
        int[] oneInteger = {42};
        AtaAddition ataAddition = new AtaAddition();

        // WHEN
        int result = ataAddition.add(oneInteger);

        // THEN
        Assertions.assertEquals(42, result,
                                "Expected adding a single int to return the int");
    }

    @Test
    public void add_twoIntegers_returnsTheirSum() {
        // GIVEN
        int[] tuple = {6, 9};
        AtaAddition ataAddition = new AtaAddition();

        // WHEN
        int result = ataAddition.add(tuple);

        // THEN
        Assertions.assertEquals(15, result,
                                "Expected adding two ints to return their sum");
    }
}

Some unit test anatomy to notice:

  • The name of the unit test class is <ClassUnderTest>Test (here AtaAdditionTest is testing AtaAddition)
  • JUnit tests are public methods that return void
  • JUnit tests are annotated with @Test to indicate to JUnit that they're unit tests to be run. If you don't annotate the method with @Test, then it'll get skipped. Note that if you wanted to create a helper method to be used by your tests, you can add that method to your test class without the @Test annotation, and make it a private method.
  • Here we're using the method naming convention of methodUnderTest_descriptiveTestCondition_expectedBehavior (we're testing the add() method and the first example above is the case of one int, where we expect the result to be that same integer). There are other conventions out there; when you join an Amazon team, follow their conventions. In ATA, we'll use this one.
  • In the assertEquals() method, we follow the best practice of providing a message (the String parameter) indicating what the test is expecting to see in the assert statement. If that assertion fails, this message is printed out, so it's a good idea to make it informative.

Note that at the top of our test class, we have the following imports. The first provides the class with the assertXXX() methods we use in the THEN sections to verify the results we expect from the WHEN section. The second gives us the @Test annotation to mark our tests:

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

Running individual tests in IntelliJ

To run an individual unit test, click on the arrow in the margin next to the method declaration and select Run '<method>', replacing <method> with the name of your method. In this example, it would be add_singleInteger_returnsTheInteger():

Run Triangle

If the test succeeds, you should see the test results in the console at the bottom of the IntelliJ window:

Test Results

Running test classes in IntelliJ

It is best practice to run ALL of your unit tests for a class at once. To do so, click on the arrow next to the test class declaration (in our example, it would be public class AtaAdditionTest..., above the first test), and select Run <class>, replacing <class> with the name of your test class (in this example, AtaAdditionTest). IntelliJ will show the results of each test in the console at the bottom of the IntelliJ window:

Run All Results

Testing beyond the "Happy" cases

It's important to test the breadth of cases that your code might need to contend with, including cases where someone might have made a mistake or use your class in unintended ways. We will consider some more test cases in this lesson's coding activity.

Practice

AtaAddition

Repo

TextTruncator

Repo

Mastery Task 1: Grace Under Pressure - Milestone 2-4

Mastery Task Guidelines

Mastery Tasks are opportunities to test your knowledge and understanding through code. When a mastery task is shown in a module, it means that we've covered all the concepts that you need to complete that task. You will want to make sure you finish them by the end of the week to stay on track and complete the unit.

Each mastery task must pass 100% of the automated tests and code styling checks to pass each unit. Your code must be your own. If you have any questions, feel free to reach out for support.

In Milestone 1, you added a FIXME into your code where you believe a bug needs to be fixed. Now you will fix the bug using a process that ensures the same bug won't reappear in the future. You will first write a unit test that will cause the error message related to the bug. Then you will fix the bug and use your new unit test to ensure the bug is properly fixed. Before jumping into the next milestone, read this brief refresher from Milestone 1 of the problem:

A CS representative has filed a bug report, stating that when they request the promise history for order ID 111-749023-7630574, the Missed Promise CLI prints a weird message and exits:

Running CLI! Please enter the orderId you would like to view the Promise History for.
> 111-749023-7630574
Error encountered. Exiting.
Thank you for using the Promise History CLI. Have a great day!

Milestone 2: Be Kind to "Future You"

Write a unit test (with a descriptive name!) that fails whenever the bug is triggered. If "Future You" accidentally re-introduces this bug, the unit test will fail and you'll know you have to fix something before you can submit it for a code review.

Which order IDs should I use in my tests?

You could try a bunch of different order IDs until you find an order that's suitable for your test. However, most order IDs are not guaranteed to be consistent, and therefore aren't suitable for testing.

To get a list of orders that are guaranteed to never change, run java -jar cli.jar --show-fixtures. This prints the known consistent orders, along with a description of their attributes, before proceeding to the CLI.

Milestone 3: Grace Hopper That Bug

Fix the bug! Then run your code and verify that your new unit test passes.

Milestone 4: Commit It

./gradlew -q clean :test

./gradlew -q clean IRT

./gradlew -q clean MasteryTaskOneTests

When all tests run successfully, commit and push your code!

Exit Checklist

  • You used the debugger to walk through the code (whether you used it to actually find the bug or not--if you see the bug, still practice with the debugger!)
  • You added a new unit test that catches the original bug
  • You fixed the bug
  • Your Mastery Task 1 TCTs are passing locally
  • You have pushed your code
  • Mastery Task 1's TCTs are passing on CodeGrade