CodeGym /Java Blog /Random /All about unit testing: techniques, concepts, practice
Konstantin
Level 36
Odesa

All about unit testing: techniques, concepts, practice

Published in the Random group
Today you won't find an application that is not draped with tests, so this topic will be more relevant than ever for novice developers: you can't succeed without tests. Let's consider what types of testing are used in principle, and then we will study in detail everything there is to know about unit testing. All about unit testing: techniques, concepts, practice - 1

Types of testing

What is a test? According to Wikipedia: "Software testing involves the execution of a software component or system component to evaluate one or more properties of interest." In other words, it is a check of the correctness of our system in certain situations. Well, let's see what types of testing there are in general:
  • Unit testing — Tests whose purpose is to check each module of the system separately. These tests should apply to the smallest atomic parts of the system, e.g. modules.
  • System testing — High-level testing to check the operation of a larger piece of the application or the system as a whole.
  • Regression testing — Testing that is used to check whether new features or bug fixes affect the application's existing functionality or introduce old bugs.
  • Functional testing — Checking whether a part of the application satisfies the requirements stated in the specifications, user stories, etc.

    Types of functional testing:

    • White-box testing — Checking whether a part of the application satisfies requirements while knowing the system's internal implementation;
    • Black-box testing — Checking whether a part of the application satisfies requirements without knowing the system's internal implementation.

  • Performance testing — Tests that are written to determine how the system or part of the system perform under a certain load.
  • Load testing — Tests designed to check the system's stability under standard loads and to find the maximum load at which the application still works correctly.
  • Stress testing — Testing designed to check the application's performance under non-standard loads and to determine the maximum load before system failure.
  • Security testing — Tests used to check the system's security (from hackers, viruses, unauthorized access to confidential data, and other delightful attacks).
  • Localization testing — Tests of the localization of the application.
  • Usability testing — Testing aimed at checking usability, understandability, attractiveness, and learnability.
This all sounds good, but how does it work in practice? Simple! We use Mike Cohn's testing pyramid:All about unit testing: techniques, concepts, practice - 2This is a simplified version of the pyramid: it is now divided into even smaller parts. But today we won't get too sophisticated. We'll consider the simplest version.
  1. Unit — This section refers to unit tests, which are applied in different layers of the application. They test the smallest divisible unit of application logic. For example, classes, but most often methods. These tests usually try as much as possible to isolate what is tested from any external logic. That is, they try to create the illusion that the rest of the application is running as expected.

    There should always be a lot of these tests (more than any other type), since they test small pieces and are very lightweight, not consuming a lot of resources (meaning RAM and time).

  2. Integration — This section refers to integration testing. This testing checks larger pieces of the system. That is, it either combines several pieces of logic (several methods or classes), or it checks the correctness of interaction with an external component. These tests are usually smaller than unit tests because they are heavier.

    An example of an integration test could be connecting to a database and checking the correctness of the operation of methods for working with it.

  3. UI — This section refers to tests that check the operation of the user interface. They involve the logic at all levels of the application, which is why they are also called end-to-end tests. As a rule, there are far fewer of them, because they are the most cumbersome and must check the most necessary (used) paths.

    In the picture above, we see that the different parts of the triangle vary in size: approximately the same proportions exist in the number of different kinds of tests in real work.

    Today we will take a closer look at the most common tests, unit tests, since all self-respecting Java developers should be able to use them at a basic level.

Key concepts in unit testing

Test coverage (code coverage) is one of the main measures of how well an application is tested. This is the percentage of the code that is covered by the tests (0-100%). In practice, many are pursue this percentage as their goal. That's something I disagree with, since it means tests start being applied where they are not needed. For example, suppose we have standard CRUD (create/get/update/delete) operations in our service without additional logic. These methods are purely intermediaries that delegate work to the layer working with the repository. In this situation, we have nothing to test, except perhaps whether the given method calls a DAO method, but that's a joke. Additional tools are usually used to assess test coverage: JaCoCo, Cobertura, Clover, Emma, etc. For a more detailed study of this topic, here are a couple of relevant articles:TDD stands for test-driven development. In this approach, before doing anything else, you write a test that will check specific code. This turns out to be black-box testing: we know the input is and we know what the output should be. This makes it possible to avoid code duplication. Test-driven development starts with designing and developing tests for each bit of functionality in your application. In the TDD approach, we first create a test that defines and tests the code's behavior. The main goal of TDD is to make your code more understandable, simpler, and error free.All about unit testing: techniques, concepts, practice - 3The approach consists of the following:
  • We write our test.
  • We run the test. Unsurprisingly, it fails, since we haven't yet implemented the required logic.
  • Add the code that causes the test to pass (we run the test again).
  • We refactor the code.
TDD is based on unit tests, since they are the smallest building blocks in the test automation pyramid. With unit tests, we can test the business logic of any class. BDD stands for behavior-driven development. This approach is based on TDD. More specifically, it uses plain language examples that explain system behavior for everyone involved in development. We will not delve into this term, since it mainly affects testers and business analysts. A test case is a scenario that describes the steps, specific conditions, and parameters required to check the code under test. A test fixture is code that sets up the test environment to have the state necessary for the method under test to run successfully. It's a predefined set of objects and their behavior under specified conditions.

Stages of testing

A test consists of three stages:
  • Specify test data (fixtures).
  • Exercise the code under test (call the tested method).
  • Verify the results and compare with the expected results.
All about unit testing: techniques, concepts, practice - 4To ensure test modularity, you need to isolate from other layers of the application. This can be done using stubs, mocks, and spies. Mocks are objects that can be customized (for example, tailored for each test). They let us specify what we expect from method calls, i.e. the expected responses. We use mock objects to verify that we get what we expect. Stubs provide a hard-coded response to calls during testing. They can also store information about the call (for example, parameters or the number of calls). These are sometimes referred to as spies. Sometimes people confuse the terms stub and mock: the difference is that a stub does not check anything — it only simulates a given state. A mock is an object that has expectations. For example, that a given method must be called a certain number of times. In other words, your test will never break because of a stub, but it might because of a mock.

Test environments

So, now to the point. There are several test environments (frameworks) available for Java. The most popular of these are JUnit and TestNG. For our review here, we use:All about unit testing: techniques, concepts, practice - 5A JUnit test is a method in a class that is used only for testing. The class is usually named the same as the class it tests, with "Test" appended to the end. For example, CarService -> CarServiceTest. The Maven build system automatically includes such classes in the test scope. In fact, this class is called a test class. Let's briefly go over the basic annotations:

  • @Test indicates that the method is a test (basically, a method marked with this annotation is a unit test).
  • @Before signifies a method that will be executed before each test. For example, to populate a class with test data, read input data, etc.
  • @After is used to mark a method that will be called after each test (e.g. to clear out data or restore default values).
  • @BeforeClass is placed above a method, analogous to @Before. But such a method is called only once before all tests for the given class and therefore must be static. It is used to perform more resource-intensive operations, such as spinning up a test database.
  • @AfterClass is the opposite of @BeforeClass: it is executed once for the given class, but only after all tests. It is used, for example, to clear persistent resources or disconnect from a database.
  • @Ignore denotes that a method is disabled and will be ignored during the overall test run. This is used in various situations, for example, if the base method has been changed and the test has not yet been reworked to accommodate the changes. In such cases, it is also desirable to add a description, i.e. @Ignore("Some description").
  • @Test(expected = Exception.class) is used for negative tests. These are tests that verify how the method behaves in case of an error, that is, the test expects the method to throw some kind of exception. Such a method is indicated by the @Test annotation, but with an indication of which error to catch.
  • @Test(timeout = 100) checks that the method is executed in no more than 100 milliseconds.
  • @Mock is used above a field to assign a mock object (this is not JUnit annotation, but instead comes from Mockito). As needed, we set the mock's behavior for a specific situation directly in the test method.
  • @RunWith(MockitoJUnitRunner.class) is placed above a class. This annotation tells JUnit to invoke the tests in the class. There are various runners, including these: MockitoJUnitRunner, JUnitPlatform, and SpringRunner. In JUnit 5, the @RunWith annotation has been replaced with the more powerful @ExtendWith annotation.
Let's take a look at some methods used to compare results:

  • assertEquals(Object expecteds, Object actuals) — checks whether the passed objects are equal.
  • assertTrue(boolean flag) — checks whether the passed value is true.
  • assertFalse(boolean flag) — checks whether the passed value is false.
  • assertNull(Object object) — checks whether the passed object is null.
  • assertSame(Object firstObject, Object secondObject) — checks whether the passed values refer to the same object.
  • assertThat(T t, Matcher matcher) — Checks whether t satisfies the condition specified in matcher.
AssertJ also provides a useful comparison method: assertThat(firstObject).isEqualTo(secondObject). Here I've mentioned the basic methods — the others are variations of the above.

Testing in practice

Now let's look at the above material in a specific example. We will test a service's update method. We will not consider the DAO layer, since we are using the default. Let's add a starter for the tests:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-test</artifactId>
   <version>2.2.2.RELEASE</version>
   <scope>test</scope>
</dependency>
And here we have the service class:

@Service
@RequiredArgsConstructor
public class RobotServiceImpl implements RobotService {
   private final RobotDAO robotDAO;

   @Override
   public Robot update(Long id, Robot robot) {
       Robot found = robotDAO.findById(id);
       return robotDAO.update(Robot.builder()
               .id(id)
               .name(robot.getName() != null ? robot.getName() : found.getName())
               .cpu(robot.getCpu() != null ? robot.getCpu() : found.getCpu())
               .producer(robot.getProducer() != null ? robot.getProducer() : found.getProducer())
               .build());
   }
}
Line 8 — pull the updated object from the database. Lines 9-14 — create an object through the builder. If the incoming object has a field, set it. If not, we'll leave what is in the database. Now look at our test:

@RunWith(MockitoJUnitRunner.class)
public class RobotServiceImplTest {
   @Mock
   private RobotDAO robotDAO;

   private RobotServiceImpl robotService;

   private static Robot testRobot;

   @BeforeClass
   public static void prepareTestData() {
       testRobot = Robot
               .builder()
               .id(123L)
               .name("testRobotMolly")
               .cpu("Intel Core i7-9700K")
               .producer("China")
               .build();
   }

   @Before
   public void init() {
       robotService = new RobotServiceImpl(robotDAO);
   }
Line 1 — our Runner. Line 4 — we isolate the service from the DAO layer by substituting a mock. Line 11 — we set a test entity (the one that we will use as a guinea pig) for the class. Line 22 — we set the service object, which is what we will test.

@Test
public void updateTest() {
   when(robotDAO.findById(any(Long.class))).thenReturn(testRobot);
   when(robotDAO.update(any(Robot.class))).then(returnsFirstArg());
   Robot robotForUpdate = Robot
           .builder()
           .name("Vally")
           .cpu("AMD Ryzen 7 2700X")
           .build();

   Robot resultRobot = robotService.update(123L, robotForUpdate);

   assertNotNull(resultRobot);
   assertSame(resultRobot.getId(),testRobot.getId());
   assertThat(resultRobot.getName()).isEqualTo(robotForUpdate.getName());
   assertTrue(resultRobot.getCpu().equals(robotForUpdate.getCpu()));
   assertEquals(resultRobot.getProducer(),testRobot.getProducer());
}
Here we see that the test has three clear divisions: Lines 3-9 — specifying fixtures. Line 11 — executing the code under test. Lines 13-17 — checking the results. In greater detail: Lines 3-4 — set the behavior for the DAO mock. Line 5 — set the instance that we will update on top of our standard. Line 11 — use the method and take the resulting instance. Line 13 — check that it is not null. Line 14 — compare the ID of the result and the given method arguments. Line 15 — check whether the name was updated. Line 16 — see the CPU result. Line 17 — we didn't specify this field in the instance, so it should remain the same. We check that condition here. Let's run it:All about unit testing: techniques, concepts, practice - 6The test is green! We can breathe a sigh of relief :) In summary, testing improves the quality of the code and makes the development process more flexible and reliable. Imagine how much effort it takes to redesign software involving hundreds of class files. When we have unit tests written for all of these classes, we can refactor with confidence. And most importantly, it helps us easily find bugs during development. Guys and gals, that's all I've got today. Give me a like, and leave a comment :)
Comments (3)
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION
Michael Level 10, Dresden, Germany
21 December 2021
Thank you for your this accessible article on testing and especially for the example!
Franco Polizzi Level 11, Werther, Germany
3 April 2021
Very nice overview. Thank you very much!
Seferi Level 22, United Federation of Planets
29 October 2020
Just what I was looking for! Thanks!