CodeGym /Courses /Spring Test /Supported security tests

Supported security tests

Spring Test
Level 23, Lesson 4
Available

1. The growth of security tests

Security tests are champions of multiplication. One protected operation already requires a happy path check, and then we suddenly multiply scenarios by “who makes the request or calls the method”: anonymous, editor, admin, plus “whose resource”, plus different error codes. If you write everything by copy-paste, tests quickly turn into a 12-season series, where nobody remembers what happened in the first episode.

The most common reason a security suite becomes “hellish” is not security itself, but the fact that we forget: a test is code too, and people read it. A maintainable security test should answer three questions in the first two seconds of inspection: who is making the request or calling the operation, what they are trying to do (an endpoint on the HTTP boundary or a service method inside the application), and what is expected (allow/deny and why). If these three things are buried among repeated strings with(user("alice")...), contentType(...), accept(...), andExpect(...), then the test starts functioning not as documentation, but as a cipher.

Let us honestly look at a typical “written yesterday” test that a week later already looks like it was written by someone else (even if you wrote it yourself — classic stuff). The examples below use JUnit 6:

import org.junit.jupiter.api.Test;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class AdminApproveSecurityTest {

    @Test
    void editorCannotApprove() throws Exception {
        mockMvc.perform(post("/api/admin/articles/42/approve")
                // Scenario actor: the request is made by an editor (this is exactly what is being checked)
                .with(user("alice").roles("EDITOR")))
            // Expect a permission denial: an editor cannot perform an admin action
            .andExpect(status().isForbidden());
    }
}

As long as this is one test, it looks fine. The problem starts when there are 40 tests like this, and each one repeats “alice”, “EDITOR”, “admin”, “password”, the same URLs, and half a page of identical expectations. At that point, the security suite starts living by the rules of old folklore: “If you touch the tests, they might get offended and break.”

Maintainability here is not about “pretty”; it is about the fact that tomorrow your access rule changes, and you want to quickly understand whether it is one fix in one place or “manual changes in 25 tests”. A good security suite makes changes local and predictable, not dramatic.

It is important here not to collapse everything back into MockMvc. In practice, security tests are not one genre, but at least three families, and maintainability matters for all three.

  1. HTTP boundary + mock actor — @WithMockUser, user(...), anonymous(...), when we quickly check the access rule for an endpoint.
  2. HTTP boundary + real credentials — httpBasic(...) or a regular Authorization header, when it is important to go through the real login mechanism.
  3. Method security + direct call to a Spring-managed bean — when the rule sits on a service method, and the test expects not an HTTP status, but an exception such as AccessDeniedException.

The main focus from here on will be the first family, because that is where the actor → endpoint → result matrix grows the fastest. But almost all of the organizational habits from this lecture carry over to the other two paths as well: meaningful actor names, compact fixtures, readable test grouping. Only the form of the checks does not carry over: status helpers are good for HTTP, while a direct service call should remain a direct service call.

2. Grouping tests and the access matrix

At the HTTP boundary, security tests really do often look like a matrix of “actor → action → expected status”. So it is logical for the structure of the test code to reflect that matrix. Otherwise you end up with a set of disconnected checks, where half the tests are about the editor, half about the admin, and everything is mixed together like wires in a box that “I’ll sort out later”. Spoiler: nobody sorts it out.

There are two healthy ways to group tests, and both can be used in one project. The first is grouping by access zone: separate test classes for public/editor/admin APIs. This is especially convenient in ContentHub, because the API zones are already reflected in the URL (/api/public/..., /api/editor/..., /api/admin/...). The second is grouping by actor inside the class: nested classes AsAnonymous, AsEditor, AsAdmin. This approach makes “who performs the request” visible not only in the code, but also in the structure of the tests.

A mini skeleton looks like this:

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;

@DisplayName("Security: /api/admin/articles")
class AdminArticleSecurityTest {

    @Nested
    @DisplayName("as ANONYMOUS")
    // Nested classes are “folders” for actors: the access matrix is visible directly from the test tree
    class AsAnonymous {
        // Tests that the request is made by anonymous will live here
    }
}

Why is this especially useful for beginners? Because you do not get the feeling that security is “magic somewhere inside Spring”. You can see it with your own eyes: here are the anonymous checks, here are the editor checks, here are the admin checks. And when a test fails, you already understand the context from the test tree.

Schematically (very roughly, but it helps the brain):

flowchart TD
    A["AdminArticleSecurityTest"] --> B["AsAnonymous"]
    A --> C["AsEditor"]
    A --> D["AsAdmin"]
    B --> B1["GET/POST -> 401"]
    C --> C1["approve -> 403"]
    D --> D1["approve -> 2xx"]

The same technique works for method-security tests: you can group them by the protected service and separate AsEditor/AsAdmin inside. Only now, instead of a URI, the matrix contains a method name, and instead of 401/403, it contains allow or AccessDeniedException.

Notice a subtle but important point: grouping should increase readability, not add bureaucracy. If you create 10 levels of nesting (AsEditor → WhenArticleIsDraft → WhenModerationOk → WhenMercuryIsInRetrograde), then the tests will look like a labyrinth, not documentation.

A very good practical balance for a security suite looks like this: one test class covers one zone or one set of endpoints, and inside that class @Nested helps keep checks for different actors close together. That way the access matrix becomes visible right in the file — without scrolling 800 lines down.

3. User fixtures: less copy-paste

In HTTP security tests, the same actors almost always repeat: alice as the editor, bob as “another editor” for owner-based scenarios, and admin. If you write these strings by hand in every test, you create two problems at once: first, the tests bloat; second, you get mismatches like roles("Admin") in one place and roles("ADMIN") in another (and then you get sad and start searching for why it is suddenly 403).

The solution is simple: create a small set of user fixtures. Not a “magical base class with 500 lines”, but literally one compact helper that creates actors consistently across the whole suite. In MVC tests, the most convenient format is a RequestPostProcessor that you pass via .with(...) into MockMvc.

A minimal TestUsers can look like this:

import org.springframework.test.web.servlet.request.RequestPostProcessor;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;

public final class TestUsers {

    private TestUsers() {
        // Utility class: instances are not needed
    }

    public static RequestPostProcessor editor(String username) {
        // Actor fixture: a single way to create an editor user for all tests
        return user(username).roles("EDITOR");
    }
}

And now let us add an admin and an anonymous user as well. The main thing is not to overcomplicate it, but to do exactly what saves noise:

import org.springframework.test.web.servlet.request.RequestPostProcessor;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;

public final class TestUsers {

    private TestUsers() {
        // Utility class: static methods only
    }

    public static RequestPostProcessor editor(String username) {
        // Keep editor in the same helper so all main actors live in one place
        return user(username).roles("EDITOR");
    }

    public static RequestPostProcessor admin() {
        // We pin down the admin explicitly: one place, one username, one role
        return user("admin").roles("ADMIN");
    }

    public static RequestPostProcessor anonymousUser() {
        // Explicitly show anonymous as a separate actor, not as null
        return anonymous();
    }
}

Yes, the admin() method looks a bit provocative as a one-liner. But this is exactly the rare case where “provocatively short” is a good thing: we save noise and do not hide meaning. A test should read like a story: “bob does a PUT there — expect forbidden.” And TestUsers.editor("bob") reads perfectly as a phrase.

The use in a test also becomes very “human”:

import org.junit.jupiter.api.Test;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class AdminApproveSecurityTest {

    @Test
    void editorGets403OnAdminApprove() throws Exception {
        mockMvc.perform(post("/api/admin/articles/42/approve")
                // Do not copy-paste roles across the suite — take them from the fixture
                .with(TestUsers.editor("alice")))
            .andExpect(status().isForbidden());
    }
}

Here is the key methodological idea: we extracted into the helper only what repeats and does not add meaning (creating a user), but did not hide the key elements of the scenario (the endpoint and the expected status). This is a very thin line, and it is easy to cross it by mistake.

Sometimes a team wants to use @WithMockUser instead of post-processors. That is fine too, especially if you group tests by actor via @Nested. Then you can put the annotation on the nested class and remove repetition:

import org.junit.jupiter.api.Nested;
import org.springframework.security.test.context.support.WithMockUser;

class AdminArticleSecurityTest {

    @Nested
    @WithMockUser(username = "admin", roles = "ADMIN")
    // You can put the annotation on the nested class so that all tests inside run “as admin”
    class AsAdmin {
    }
}

If you use @WithMockUser, it is very useful to keep usernames meaningful. alice and bob in owner-based scenarios work like characters in a book: it is immediately clear who is “not the owner” of whom. If you write user1 and user2, the brain has to do extra work — and in tests we are trying to save brainpower.

For the method-security path, the principle is the same: the actor can be set by an annotation on the nested class, but defaultUser is just as harmful there as it is in MockMvc tests.

4. Owner-based scenarios: user and data

Owner-based access is the place where beginners most often write a “seemingly correct test” that fails (or passes) for a completely different reason. For example, you wanted to check that “bob cannot read alice’s draft”, but the article was never created — and you got 404. Formally this is still “denied”, but the meaning is different: this is no longer an owner-based denial, but a missing resource. Such a test is misleading, because it checks something other than what its name says.

That is why in owner-based tests, the data fixture is just as important as the user fixture. You need to explicitly create the article, explicitly assign the owner, and only then make the request as the “other” user. The idea sounds obvious, but this is exactly where chaos usually begins: “let us just use id 42, it exists somewhere” (and then it stops existing somewhere).

The simplest way to stabilize owner-based scenarios is to keep a separate test helper for preparing the article, so that the test keeps its meaning instead of the details of entity creation. For example, a small TestArticles factory (in test code) can create a “minimally valid” article:

public final class TestArticles {

    private TestArticles() {
        // Utility class: test entity factory
    }

    public static Article draftOwnedBy(String username, Category category) {
        Article a = new Article();
        // Minimally valid fields: so that the test fails because of security, not validation/DB constraints
        a.setTitle("Test title");
        a.setSummary("Test summary");
        a.setBody("Test body");
        a.setStatus(ArticleStatus.DRAFT);
        // Key part of the owner-based fixture: who owns the resource
        a.setAuthorUsername(username);
        a.setCategory(category);
        return a;
    }
}

Yes, this is no longer security-specific code, but it solves the owner-based test problem: the data becomes understandable, not random.

And here is what the test itself may look like (the idea matters more than the exact repository/field names):

import org.junit.jupiter.api.Test;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class EditorArticleOwnerSecurityTest {

    @Test
    void bobGets403ForAlicesDraft() throws Exception {
        Category tech = categoryRepository.findByCode("TECH").orElseThrow();
        // Fix the resource owner: the article belongs to alice
        Article a = articleRepository.save(TestArticles.draftOwnedBy("alice", tech));

        mockMvc.perform(get("/api/editor/articles/" + a.getId())
                // Fix the actor: the request is made by bob (not the owner)
                .with(TestUsers.editor("bob")))
            .andExpect(status().isForbidden());
    }
}

Notice that there are two context fixes here. The first is in the data: the article belongs to alice. The second is in the actor: the request is made by bob. And only in this setup will the 403 result truly mean “owner-based denial”, rather than “the article was accidentally not found”.

If your project uses SQL-based data setup (and we already know how to work with @Sql from the data/integration block of the course), then owner-based scenarios can be made even more schematic: pin down a specific article id (for example, 42L) and the owner directly in the SQL file. This is sometimes even clearer than creating the entity in Java, because the reader sees “here is a concrete row in the database” and understands what the scenario rests on.

The main rule remains the same: an owner-based test should fail because of “not the owner”, not because of “does not exist” or “wrong status”.

5. Mini-DSL: helpers without magic

When you have already created user fixtures, there is a temptation to “go further” and write a mini testing framework: performAsEditor().approve().expectDenied(). It is a sweet temptation, because it seems like there will be less code. But here it is easy to lose the main thing: the test stops being a simple read and turns into reading someone else’s DSL (which you also have to maintain yourself).

A good middle ground is helpers that remove technical noise but keep the key elements of the scenario in view. For example, in security tests accept(MediaType.APPLICATION_JSON) and the constant combination .with(actor) often repeat. You can move that into a couple of methods inside the test class (or a small utility) without hiding the URL or the expected status.

An example of a “healthy” helper:

import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.RequestPostProcessor;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

ResultActions getAs(RequestPostProcessor actor, String url) throws Exception {
    // The helper removes technical noise (accept + with), but does not hide the URL and does not hide expectations (they remain in the test)
    return mockMvc.perform(get(url).with(actor).accept(MediaType.APPLICATION_JSON));
}

Now the test reads more easily, but it is still literal and transparent:

import org.junit.jupiter.api.Test;

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class EditorArticleSecurityTest {

    @Test
    void anonymousGets401OnEditorRead() throws Exception {
        // Here it is important to clearly see: anonymous calls an editor endpoint => expect 401
        getAs(TestUsers.anonymousUser(), "/api/editor/articles/42")
            .andExpect(status().isUnauthorized());
    }
}

Notice that we did not hide the endpoint itself. That matters: in HTTP security tests, the endpoint is part of the “access contract”, and it should be visible.

There is a useful technique that works especially well in ContentHub: moving URL templates into constants. Not in order to “move everything into constants because that is the rule”, but to avoid typos and to keep route knowledge from being spread across the whole suite:

private static final String EDITOR_ARTICLE_BY_ID = "/api/editor/articles/{id}";
private static final String ADMIN_APPROVE = "/api/admin/articles/{id}/approve";

Then the tests become even clearer: you are not reading “URL strings”, you are reading “action names”.

To avoid sliding into a “magic DSL”, it helps to keep a simple table in mind (you can think of it as an internal checklist):

What to extract into a helper Why that is okay What is better left alone Why that is dangerous
User creation (TestUsers.admin()) repeats, adds no meaning expected status the reader should see 401/403/200 directly in the test
accept(MediaType.APPLICATION_JSON) / contentType(MediaType.APPLICATION_JSON) technical noise the full “create + submit + approve” flow that is already business flow and you lose the security focus
generation of a minimally valid article for owner-based scenarios otherwise the test is accidental “universal perform()” with hidden URL then it becomes impossible to understand what is being checked

If you still want to reduce duplication of ApiProblem checks a little bit (for example, errorCode = ACCESS_DENIED), do it carefully. A good compromise is a small helper that adds one readable check, but does not turn the test into a black box. For example, a method expectAccessDenied(...) that checks 403 and errorCode, but is called directly in the test and does not hide the context.

6. Typical mistakes when organizing security tests

Mistake #1: a “magical” base class for all security tests.
Sometimes people want to create an AbstractSecurityTest that configures everything at once: users, data, helper methods, common assertions, and even “convenient” magic on top of MockMvc. The problem is that in a couple of days you do not get maintainability, but a second application inside the tests. When a test fails, you first debug the base class, then the test. For junior-level work and for an educational project, this is almost always unnecessary complexity. Better small utilities and local helpers inside the specific test class.

Mistake #2: a helper like loginAsDefaultUser() without role and username.
Such a helper saves two lines, but breaks the meaning. In a security test, it is important to clearly see the actor. If the method hides who exactly is logged in, the test stops being documentation of the access matrix and turns into a guessing game. As a result, you read the test and think: “So who is actually making the request here?”

Mistake #3: an owner-based test without an owner fixture.
If you check that “bob cannot read alice’s article”, but the article is not created, or is created without an author, or the author does not match the username, then the test either fails with 404 or passes accidentally. In both cases, you lose the point: the test does not prove the owner-based rule. An owner-based scenario must rest on clear data: the resource exists and belongs to another user.

Mistake #4: mixing denial reasons in one test.
When in one test you check access (security), a business status transition (workflow), and an error format, the test becomes “everything at once”. It becomes fragile: change a business rule — and the security test fails, and you start doubting what broke. It is better to keep a security test as a check of “allowed/not allowed”, while business logic lives in its own layers (unit/integration), as we already did before.

Mistake #5: copy-pasting role and URL strings across the whole suite.
Today you wrote "EDITOR" in ten places — and everything is green. Tomorrow you rename a role, or you add a new one, or the URL changes, and you search for string literals across the project. This is not a “disaster”, but it is a loss of time and nerves. User fixtures and URL constants solve the problem very cheaply, and that is exactly why they pay off almost immediately.

1
Task
Spring Test, level 23, lesson 4
Locked
User fixtures without copy-paste in HTTP security tests
User fixtures without copy-paste in HTTP security tests
1
Task
Spring Test, level 23, lesson 4
Locked
Grouping security tests by actor through `@Nested`
Grouping security tests by actor through `@Nested`
1
Survey/quiz
Access check, level 23, lesson 4
Unavailable
Access check
Spring Security testing
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION