Comprehensive Guide to Testing REST APIs: Best Practices, Tools, and Examples

Sudip ParajuliSudip Parajuli
9 min read

As a software developer, you know that writing code is just the start. To make sure your app works as expected and lasts, you need to test it thoroughly. This is especially important for building REST APIs, where a bug can have big consequences.

In this post, we'll dive into testing REST APIs. We'll look at different types of tests you can do and tools to make the process smoother. Whether you're an experienced developer or just starting, this guide will give you the knowledge and examples you need to write robust and maintainable tests for your API.

Types of Tests for REST APIs

When it comes to testing REST APIs, there are several types of tests you can perform, each with its purpose and benefits. Let's take a closer look at them:

Various Types of Te­sting for REST APIs

When dealing with REST API testing, you have­ multiple test categorie­s. Each serves its purpose and provide­s specific advantages. Let's e­xplore these te­sting types:

Unit Testing

Unit testing is fundame­ntal. You verify individual code units, like classe­s or methods. It helps catch bugs early in de­velopment. Unit tests e­nsure your code works properly.

Inte­gration Testing

Integration testing goe­s beyond unit testing. It examine­s how different components inte­ract, including your API's integration with external syste­ms or services it utilizes.

Contract Te­sting

RehumanizeContract testing certifies that change­s made to your API do not break any existing consume­r contracts. This prevents issues whe­n clients update their applications.

End-to-End (E2E) Testing

This type­ checks the whole use­r trip. From front-end clicks to backend code. It spots issue­s when parts work together. Not single­ parts.

User Acceptance Te­sting (UAT)

Real users perform this final te­st. They ensure the­ API meets business ne­eds. It happens before­ code goes live. UAT is the­ last approval step.

Testing Tools: JUnit and Mockito

junit-mockito

When it comes to testing Java-based REST APIs, two of the most popular tools are JUnit and Mockito. Let's take a closer look at how these tools can help you write effective tests.

JUnit

JUnit is a widely adopted unit testing framework for Java. It provides a set of annotations and assertions that make it easy to write and run tests. JUnit 5, the latest version of the framework, offers several improvements over previous versions, including better support for modern Java features and improved extensibility.

Annotations:

  1. @Test annotation specifies that the method is the test method.

  2. @ParameterizedTest make it possible to run a test multiple times with different arguments. They are declared just like regular @Test methods but use @ParameterizedTest annotation instead.

  3. @BeforeEach annotation specifies that the annotated method should be executed before each test method, analogous to JUnit 4’s @Before.

  4. @BeforeAll annotation is used to execute the annotated method, only after all tests have been executed. This is analogous to JUnit 4’s @AfterClass.

  5. @DisplayName annotation is used to declare custom display names that test runners and test reports will display.

  6. @AfterEach annotation specifies that the method should be executed after each test method, analogous to JUnit 4’s @After.

  7. @AfterAll annotation is used to execute the annotated method, only after all tests have been executed. This is analogous to JUnit 4’s @AfterClass

Mockito

Mockito is a popular mocking framework that works seamlessly with JUnit. It allows you to replace external dependencies (such as databases or web services) with test doubles, making your unit tests faster, more reliable, and easier to maintain.

Annotations:

  1. @Mock annotation is used to create and inject mocked instances. We do not create real objects, rather ask Mockito to create a mock for the class.

  2. @InjectMocks annotation marks a field on which injection should be performed**.** Mockito will try to inject mocks only either by constructor injection, setter injection, or property injection

  3. @Spy annotation is used to create a real object and spy on that real object. A spy helps to call all the normal methods of the object while still tracking every interaction, just as we would with a mock.

  4. @Captor annotation is used to create an ArgumentCaptor instance which is used to capture method argument values for further assertions.

Here's an example of how you can use JUnit5/Mockito to test simple REST API endpoints:

package com.sudip.rest.webservices.restfulwebservices.todo;

import com.sudip.rest.webservices.restfulwebservices.todo.repository.TodoRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
public class TodoController {

    private final TodoRepository todoRepository;

    public TodoController(TodoRepository todoRepository)
    {
        this.todoRepository = todoRepository;
    }

    // Retrieve all todos
    @GetMapping("/users/{username}/todos")
    public List<Todo> retrieveTodos(@PathVariable String username){
        return todoRepository.findByUsername(username);
    }

    // Retrieve one todos
    @GetMapping("/users/{username}/todos/{id}")
    public Todo retrieveTodo(@PathVariable String username, @PathVariable int id){
        return todoRepository.findById(id).get();
    }

    // Delete todos by id
    @DeleteMapping("/users/{username}/todos/{id}")
    public ResponseEntity<Void> deleteTodo(@PathVariable String username, @PathVariable int id){
        todoRepository.deleteById(id);
        return ResponseEntity.noContent().build();
    }
}

The TodoController class is a Spring Boot REST controller that handles the retrieval and deletion of todo items. It has two main methods:

  1. retrieveTodos(String username): This method is mapped to the /users/{username}/todos endpoint and returns a list of all todo items for the specified username.

  2. retrieveTodo(String username, int id): This method is mapped to the /users/{username}/todos/{id} endpoint and returns the todo item with the specified ID for the given username.

  3. deleteTodo(String username, int id): This method is mapped to the /users/{username}/todos/{id} endpoint and deletes the todo item with the specified ID for the given username.

package com.sudip.rest.webservices.restfulwebservices;

import com.sudip.rest.webservices.restfulwebservices.todo.Todo;
import com.sudip.rest.webservices.restfulwebservices.todo.TodoController;
import com.sudip.rest.webservices.restfulwebservices.todo.repository.TodoRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import java.util.Collections;
import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
 * Unit tests for the TodoController class.
 */
@ExtendWith(MockitoExtension.class)
class TodoControllerTests {

    @Mock
    private TodoRepository todoRepository;

    @InjectMocks
    private TodoController todoController;

    /**
     * Test case to verify the successful retrieval of todos.
     */
    @Test
    void retrieveTodos_Success() {
        // Arrange
        String username = "testUser";
        List<Todo> todos = Collections.singletonList(new Todo());
        when(todoRepository.findByUsername(username)).thenReturn(todos);

        // Act
        List<Todo> result = todoController.retrieveTodos(username);

        // Assert
        assertEquals(todos.size(), result.size());
        verify(todoRepository).findByUsername(username);
    }

    /**
     * Test case to verify the successful retrieval of a single todo.
     */
    @Test
    void retrieveTodo_Success() {
        // Arrange
        int id = 1;
        Todo todo = new Todo();
        when(todoRepository.findById(id)).thenReturn(Optional.of(todo));

        // Act
        Todo result = todoController.retrieveTodo("testUser", id);

        // Assert
        assertEquals(todo, result);
        verify(todoRepository).findById(id);
    }

    /**
     * Test case to verify the successful deletion of a todo.
     */
    @Test
    void deleteTodo_Success() {
        // Arrange
        int id = 1;

        // Act
        ResponseEntity<Void> responseEntity = todoController.deleteTodo("testUser", id);

        // Assert
        assertEquals(HttpStatus.NO_CONTENT, responseEntity.getStatusCode());
        verify(todoRepository).deleteById(id);
    }

    /**
     * Test case to verify the retrieval of todos when the list is empty.
     */
    @Test
    void retrieveTodos_EmptyList() {
        // Arrange
        String username = "testUser";
        when(todoRepository.findByUsername(username)).thenReturn(Collections.emptyList());

        // Act
        List<Todo> result = todoController.retrieveTodos(username);

        // Assert
        assertEquals(0, result.size());
        verify(todoRepository).findByUsername(username);
    }
}

Here's a breakdown of the code:

  1. Imports: The test class imports the necessary classes and annotations, including the Todo, TodoController, and TodoRepository classes, as well as the JUnit5 and Mockito libraries for testing.

  2. TodoControllerTest Class: This class is responsible for testing the TodoController class.

  3. Field Declarations: Here, we declare fields for the TodoRepository and TodoController. The @Mock annotation creates a mock instance of TodoRepository, and the @InjectMocks annotation injects it into todoController.

  4. Setup Method: This @BeforeEach the method is called before each test and initializes the Mockito mocks using the MockitoAnnotations.openMocks(this) method.

  5. Test Methods: These are the actual test methods. Each method is annotated with @Test to indicate that it is a test case.

  6. retrieveTodos_Success Test: This test verifies the successful retrieval of todos. It sets up a mock TodoRepository to return a list of Todo objects when the findByUsername the method is called. It then calls the retrieveTodos method of the TodoController and asserts that the returned list has the same size as the expected list. Finally, it verifies that the findByUsername method of the TodoRepository was called.

  7. retrieveTodo_Success Test: This test verifies the successful retrieval of a single todo. It sets up a mock TodoRepository to return a Optional containing an Todo object when the findById the method is called. It then calls the retrieveTodo method TodoController and asserts that the returned Todo the object is the same as the expected one. Finally, it verifies that the findById method of the TodoRepository was called.

  8. deleteTodo_Success Test: This test verifies the successful deletion of a todo. It calls the deleteTodo method of the TodoController and asserts that the returned ResponseEntity has a status code of HttpStatus.NO_CONTENT. It also verifies that the deleteById method of the TodoRepository was called.

  9. retrieveTodos_EmptyList Test: This test verifies that when the retrieveTodos method of TodoController is called with a specific username and the findByUsername method of TodoRepository returns an empty list, the returned list from retrieveTodos is also empty. It ensures that the controller behaves correctly when there are no todos for the given username.

Best Practices for Effective Test Writing

Writing effective tests is an art, and it takes practice to master. Here are some best practices to keep in mind:

  1. Names that Speak for Themselves

Let's face it, clever test names are fun, but clarity is king. Your test names should be like mini-sentences, telling everyone exactly what functionality is being tested and what the expected outcome is. Think of it as a quick cheat sheet for you or a future teammate to understand the test's purpose at a glance.

  1. Break Everything That Could Possibly Break

When writing tests, think like a gremlin whose sole purpose is to break your code. Don't just test the happy path – go after those edge cases and weird inputs that might send your code into a tailspin. By testing these extremes, you're proactively identifying potential issues before they wreak havoc in production.

  1. Focus on the Big Wins

There's no need to write a test for every single line of code, especially getters and setters. These are usually pretty straightforward. Instead, prioritize testing the core logic of your application, the parts where bugs are more likely to lurk.

  1. Invalid Input? No Problem!

Imagine a user throwing unexpected data at your code. Can it handle it gracefully? Make sure your tests cover these scenarios where invalid parameters are passed to your functions. This ensures your application doesn't crash and burn but instead provides clear error messages to users.

  1. Tests are Living Documentation

Ever spend hours debugging only to realize a well-written test could have saved the day? When you find a bug, resist the urge to jump straight into debug mode. Instead, write a targeted test that exposes the issue. This not only helps you squash the bug but also strengthens your test suite and acts as living documentation for your code's behavior.

  1. Keeping away from@ignore

That @ignore annotation might be tempting for flaky tests, but fight the urge! Skipped tests provide no value. If a test is truly broken, fix it or remove it entirely. A clean test suite is a happy test suite (and a happy programmer).

  1. Logs, Not Screams

When you need to peek into your test's inner workings, use logs instead of print statements. Print statements can clutter your output, making it hard to decipher what's going on. Logs provide a cleaner way to track test execution and ensure your tests are focused on the core functionality, not debugging messages.

Conclusion

By following these practices, you'll be well on your way to writing effective tests that keep your code healthy and your sanity intact. Remember, good tests are an investment in the future – they'll save you time, and frustration, and help you ship high-quality code.

5
Subscribe to my newsletter

Read articles from Sudip Parajuli directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sudip Parajuli
Sudip Parajuli

I am an aspiring Developer from Nepal.