If you have just started learning API test automation in Java, you have probably heard the name REST Assured more than once. It is one of the most common libraries for testing REST APIs, and for good reason: it lets you write requests and assertions in a way that reads almost like plain English.

The tricky part is usually finding an API to practice on. Public APIs often need an account, a paid key, or rate limits that get in the way while you are still learning. The Rick and Morty API in Fun API Playground avoids all of that. It is free, it needs no signup, and the data (characters, locations, and episodes from the show) is easy to reason about, which means you can focus on REST Assured itself instead of figuring out what the data even means.

In this guide we will set up a small REST Assured project, write a handful of tests against real endpoints, and work our way up to authenticated requests. By the end you will have a working test class you can keep extending on your own.

Before We Start

You will need Java and a build tool, either Maven or Gradle. If you already have a Java project set up, you can skip ahead to adding the dependency.

For Maven, add this to your pom.xml:

<dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>rest-assured</artifactId>
    <version>5.4.0</version>
    <scope>test</scope>
</dependency>

If you are using Gradle:

testImplementation 'io.rest-assured:rest-assured:5.4.0'

The examples below also use JUnit 5, so make sure that is on your classpath too. Every request in this tutorial is built on the same base URL:

https://funapi.dev/api/rickandmorty/v1

Keep the Rick and Morty API documentation open in another tab if you want to see the full list of endpoints beyond the ones covered here.

Your First Test: Listing Characters

Let's start with the simplest possible request, a GET to /characters. Create a test class and add this:

import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;

class RickAndMortyApiTest {

    @BeforeAll
    static void setup() {
        RestAssured.baseURI = "https://funapi.dev/api/rickandmorty/v1";
    }

    @Test
    void listCharactersReturnsOk() {
        given()
        .when()
            .get("/characters")
        .then()
            .statusCode(200)
            .body("data.size()", greaterThan(0))
            .body("data[0].name", notNullValue());
    }
}

Setting RestAssured.baseURI once in @BeforeAll means every test after this one only needs to reference the path, not the full URL. That keeps the tests focused on what actually changes between them.

Run the test. It should pass, and if you print the response body you will see something like this:

{
  "page": 1,
  "pageSize": 4,
  "total": 8,
  "totalPages": 2,
  "data": [
    { "id": 1, "name": "Rick Sanchez", "species": "Human", "status": "Alive", "locationId": 1 },
    { "id": 2, "name": "Morty Smith", "species": "Human", "status": "Alive", "locationId": 1 }
  ]
}

Just like a lot of real-world APIs, the character list is paginated rather than returned all at once. The data array holds the actual records, and the fields around it describe the current page.

Getting One Character by ID

Path parameters are the next thing worth practicing, since almost every REST API uses them somewhere. Add this test:

@Test
void getRickSanchezById() {
    given()
    .when()
        .get("/characters/1")
    .then()
        .statusCode(200)
        .body("name", equalTo("Rick Sanchez"))
        .body("species", equalTo("Human"));
}

The 1 at the end of the path identifies which character we want. REST Assured lets you assert directly on fields in the JSON response using a dot-notation path, so body("name", equalTo("Rick Sanchez")) reads almost the same way you would describe the check out loud.

Filtering with a Query Parameter

Query parameters work differently from path parameters: instead of identifying one resource, they narrow down a list. The /characters endpoint accepts an optional status filter, so let's use it:

@Test
void filterCharactersByStatus() {
    given()
        .queryParam("status", "Dead")
    .when()
        .get("/characters")
    .then()
        .statusCode(200)
        .body("data.status", everyItem(equalTo("Dead")));
}

everyItem(equalTo("Dead")) checks that every single element in the data.status list matches, not just the first one. This is a small but useful pattern: it is easy to write a test that only checks the first item and misses a bug further down the list.

Testing a Nested Resource

Some endpoints return data related to another resource rather than the resource itself. /characters/{id}/location is one of them, it returns the location a character is currently at:

@Test
void getBirdpersonsLocation() {
    given()
    .when()
        .get("/characters/6/location")
    .then()
        .statusCode(200)
        .body("name", equalTo("Citadel of Ricks"));
}

Character 6 is Birdperson, and in this sandbox he is based at the Citadel of Ricks. Nested resources like this are common in real APIs (think /orders/{id}/items or /users/{id}/roles), so it is worth getting comfortable testing them early.

Testing the Negative Case

It is tempting to only test the happy path, but the way an API fails matters just as much as the way it succeeds. Let's request a character that does not exist:

@Test
void unknownCharacterReturns404() {
    given()
    .when()
        .get("/characters/999")
    .then()
        .statusCode(404)
        .body("error", equalTo("Not Found"))
        .body("message", containsString("999"));
}

A well-behaved API does not crash or return an empty 200 when it can't find something, it returns a clear 404 with a message that actually explains what went wrong. Checking both the status code and the body content in the same test is a good habit: the status code tells you the category of the response, and the body tells you whether the explanation makes sense.

Adding a Character with Authentication

So far every request has been read-only. Creating a character needs a POST request, and this endpoint is protected, so we also need to send a Bearer token. Fun API Playground ships with a couple of fixed demo tokens for exactly this kind of practice:

@Test
void addNewCharacterWithToken() {
    String requestBody = """
        {
          "name": "Evil Morty",
          "species": "Human",
          "status": "Alive",
          "locationId": 2
        }
        """;

    given()
        .header("Authorization", "Bearer qa-admin-token")
        .contentType("application/json")
        .body(requestBody)
    .when()
        .post("/characters")
    .then()
        .statusCode(201)
        .body("name", equalTo("Evil Morty"))
        .body("id", notNullValue());
}

A 201 Created tells you the request did not just succeed, it also created something new. That is a different signal from a 200 OK, and it is worth asserting on the status code exactly rather than treating "anything in the 200s" as good enough, especially once you start writing tests that check what got created.

Checking What Happens Without a Token

Now let's remove the Authorization header entirely and send the same request:

@Test
void addCharacterWithoutTokenReturns401() {
    String requestBody = """
        { "name": "Evil Morty", "species": "Human" }
        """;

    given()
        .contentType("application/json")
        .body(requestBody)
    .when()
        .post("/characters")
    .then()
        .statusCode(401);
}

This should come back as 401 Unauthorized. Testing the missing-token case separately from the happy path might feel repetitive, but it is exactly the kind of test that catches a security regression, for example if someone accidentally removes the auth check while refactoring the endpoint later.

Role-Based Access: Not Every Token Can Do Everything

Fun API Playground also has a second demo token, qa-viewer-token, which can read and write but is not allowed to delete anything. This is a good way to practice testing role-based access, something you will run into constantly in real projects:

@Test
void viewerTokenCannotDeleteCharacter() {
    given()
        .header("Authorization", "Bearer qa-viewer-token")
    .when()
        .delete("/characters/8")
    .then()
        .statusCode(403);
}

Notice this is different from the 401 we saw earlier. A 401 means "I don't know who you are." A 403 means "I know who you are, and you're not allowed to do this." Mixing the two up is a common mistake, both in test code and in the API implementations being tested, so it is worth asserting on the exact code rather than just checking that the request "failed."

Where to Go Next

At this point you have covered a GET list, a GET by ID, query filtering, a nested resource, a 404, an authenticated POST, a missing-token 401, and a role-based 403. That is a solid foundation for testing almost any REST API, not just this one.

A natural next step is the update endpoint:

PUT https://funapi.dev/api/rickandmorty/v1/characters/{id}

It supports an If-Match header for optimistic concurrency, so sending a stale value on purpose is a good way to practice testing 412 Precondition Failed. You could also try grouping the setup shown here into a shared RequestSpecification, which is how most real REST Assured projects avoid repeating the base URL and headers in every single test.

The full list of endpoints, including locations and episodes, is available in the Rick and Morty API documentation whenever you are ready to keep going.