Senior 5 min · March 06, 2026

Software Testing Types — Silent Regression Loop

Infinite redirect loop after discount change: unit tests passed, but checkout never loaded.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Software testing is a multi-layer discipline: each type catches a specific class of bugs.
  • Unit tests check one method in isolation — fast, cheap, and pinpoint failures.
  • Integration tests verify components work together — they catch data contract mismatches.
  • System testing treats the app as a black box; acceptance testing validates user requirements.
  • Regression tests run automatically after every change to prevent new code from breaking old features.
  • The testing pyramid: many fast unit tests, fewer slower integration tests, very few end-to-end tests.
Plain-English First

Imagine you're building a LEGO spaceship. First you check each individual brick isn't cracked (unit testing). Then you check that two bricks snap together properly (integration testing). Then you check the whole finished spaceship looks right and flies straight (system testing). Finally, you hand it to your little sister and ask 'is this what you wanted?' (acceptance testing). Software testing works exactly the same way — you check the small pieces, then how they connect, then the whole thing, then whether the real user is happy.

Every year, software bugs cost the global economy over $2 trillion. The famous Ariane 5 rocket exploded 37 seconds after launch in 1996 because of a single untested integer overflow. In 2012, Knight Capital Group lost $440 million in 45 minutes due to a deployment with untested code. These aren't edge cases — they're what happens when testing is skipped, rushed, or misunderstood. Testing isn't a chore you do at the end; it's the engineering discipline that separates professional software from dangerous guesswork.

The problem most beginners face is that 'testing' sounds like one thing, but it's actually a whole family of disciplines, each solving a different problem at a different stage of development. Trying to catch every bug with one type of test is like trying to diagnose every car problem by just taking it for a test drive — you'll miss things that only a mechanic with the hood open would catch. Different testing types exist because different kinds of failures hide in different places.

By the end of this article you'll be able to name and explain every major software testing type, understand exactly when and why each one is used, read a testing strategy in a job description and know what it means, write basic unit and integration tests in Java, and walk confidently into an interview question about testing without freezing up. Let's build this from the ground up.

Unit Testing — Checking Every Single Brick Before You Build

A unit test checks the smallest possible piece of your code in complete isolation. We're talking one method, one function, one tiny behaviour — nothing more. The word 'unit' literally means the smallest meaningful chunk.

Why isolation? Because if ten things can all affect your test, and it fails, you have no idea which one broke. Isolation means when a unit test fails, the guilty code is almost certainly right in front of you.

Unit tests are fast — we're talking milliseconds each — so you can run thousands of them in seconds. That speed is the whole point. You want instant feedback every time you change code. Think of unit tests as your safety net: they don't stop you from falling, but they catch you immediately when you do.

In Java, JUnit is the standard framework. Notice in the example below how each test method checks exactly ONE behaviour of the calculator. We don't mix concerns. We test addition in one method, division by zero in another. That granularity is what makes unit tests so powerful as a diagnostic tool — when one fails, the failure message tells you exactly what broke.

CalculatorTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;

// This is the class we want to test
class Calculator {

    // Adds two integers and returns the result
    public int add(int firstNumber, int secondNumber) {
        return firstNumber + secondNumber;
    }

    // Divides numerator by denominator
    // Throws ArithmeticException if denominator is zero
    public double divide(double numerator, double denominator) {
        if (denominator == 0) {
            throw new ArithmeticException("Cannot divide by zero");
        }
        return numerator / denominator;
    }

    // Returns true if a number is even
    public boolean isEven(int number) {
        return number % 2 == 0;
    }
}

// The test class — JUnit discovers methods annotated with @Test
public class CalculatorTest {

    // Create ONE shared instance of the thing we're testing
    Calculator calculator = new Calculator();

    @Test
    @DisplayName("Adding two positive numbers returns their sum")
    void testAdditionOfTwoPositiveNumbers() {
        // ARRANGE — set up the inputs
        int firstNumber = 7;
        int secondNumber = 3;

        // ACT — call the method under test
        int result = calculator.add(firstNumber, secondNumber);

        // ASSERT — verify the result is what we expect
        assertEquals(10, result, "7 + 3 should equal 10");
    }

    @Test
    @DisplayName("Adding a positive and a negative number works correctly")
    void testAdditionWithNegativeNumber() {
        int result = calculator.add(10, -4);
        // Negative numbers are a classic edge case — always test them
        assertEquals(6, result, "10 + (-4) should equal 6");
    }

    @Test
    @DisplayName("Dividing by zero throws an ArithmeticException")
    void testDivisionByZeroThrowsException() {
        // assertThrows checks that calling this code DOES throw the expected exception
        // If it does NOT throw, the test FAILS
        assertThrows(
            ArithmeticException.class,
            () -> calculator.divide(10, 0),
            "Dividing by zero must throw ArithmeticException"
        );
    }

    @Test
    @DisplayName("Even number check returns true for 4")
    void testIsEvenReturnsTrueForEvenNumber() {
        assertTrue(calculator.isEven(4), "4 is even, so isEven should return true");
    }

    @Test
    @DisplayName("Even number check returns false for 7")
    void testIsEvenReturnsFalseForOddNumber() {
        assertFalse(calculator.isEven(7), "7 is odd, so isEven should return false");
    }
}
Output
Test run finished after 18 ms
[ 5 tests found ]
[ 5 tests started ]
[ 5 tests successful ]
[ 0 tests failed ]
✔ Adding two positive numbers returns their sum
✔ Adding a positive and a negative number works correctly
✔ Dividing by zero throws an ArithmeticException
✔ Even number check returns true for 4
✔ Even number check returns false for 7
Pro Tip: The AAA Pattern
Every unit test you ever write should follow Arrange → Act → Assert. Arrange sets up your inputs, Act calls the method being tested, Assert checks the result. If you can't split your test into these three steps, your test is probably doing too much. Keep each test method focused on exactly one behaviour.
Production Insight
Unit tests are your fastest feedback loop, but they're structurally blind to integration issues.
A test suite that's 99% unit tests with zero integration tests will ship broken APIs.
Rule: use unit tests for logic, integration tests for boundaries.
Key Takeaway
Unit tests verify one behaviour in isolation.
When a unit test fails, the guilty code is almost certainly in that method.
One method, one test, one reason to fail.
When to Write a Unit Test
IfMethod has no external dependencies (no DB, no network, no files)
UseDefinitely write a unit test. It's cheap and fast.
IfMethod depends on an external service via an interface
UseWrite a unit test with a mock. But also write an integration test against the real service.
IfMethod directly calls database or filesystem
UseThis isn't a unit — it's an integration point. Write an integration test instead.

Integration Testing — Do the Bricks Actually Snap Together?

Unit tests proved each brick works alone. Integration testing answers a different and equally important question: when two or more components talk to each other, does that conversation work correctly?

Here's why this matters separately. You could have a perfectly written database service and a perfectly written user service, both passing all their unit tests, and they could still fail when they try to communicate — because the database service returns data in a format the user service doesn't expect. Neither unit test would catch that. Integration tests do.

Think of it like this: a restaurant kitchen (your backend) might be brilliant at cooking (unit-level). But if the waiter (your API layer) brings the wrong order to the wrong table, the food being perfect doesn't help. Integration testing checks the handoff.

Common things integration tests check: a service correctly reading from and writing to a real (or realistic) database, two microservices communicating over HTTP, a method that depends on an external file or config being read correctly.

Integration tests are slower than unit tests because they involve real connections, real databases (or close simulations), and real I/O. That's why you run fewer of them, but they're not optional — they catch an entire category of bugs that unit tests are structurally incapable of finding.

UserRepositoryIntegrationTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

// A simple in-memory "database" simulating a real data store
// In a real integration test this would be a test database (e.g. H2 for Java)
class InMemoryUserDatabase {
    private final Map<Integer, String> userStore = new HashMap<>();
    private int nextId = 1;

    // Saves a user and returns the auto-generated ID (like a real DB would)
    public int saveUser(String userName) {
        int assignedId = nextId++;
        userStore.put(assignedId, userName);
        return assignedId;
    }

    // Finds a user by ID — returns Optional to handle "not found" cleanly
    public Optional<String> findUserById(int userId) {
        return Optional.ofNullable(userStore.get(userId));
    }

    // Clears all data — useful for resetting state between tests
    public void clearAll() {
        userStore.clear();
        nextId = 1;
    }
}

// The service layer — it DEPENDS on the database. This dependency is what
// integration tests exercise. Unit tests would mock the database away.
class UserRegistrationService {
    private final InMemoryUserDatabase userDatabase;

    // The database is injected — this is dependency injection in action
    public UserRegistrationService(InMemoryUserDatabase userDatabase) {
        this.userDatabase = userDatabase;
    }

    // Registers a new user after basic validation
    public int registerUser(String userName) {
        if (userName == null || userName.isBlank()) {
            throw new IllegalArgumentException("Username cannot be empty");
        }
        // Delegates to the database — this is the integration point under test
        return userDatabase.saveUser(userName.trim());
    }

    // Looks up a user by their ID
    public String getUserById(int userId) {
        return userDatabase.findUserById(userId)
            .orElseThrow(() -> new RuntimeException("User with ID " + userId + " not found"));
    }
}

// Integration test — testing UserRegistrationService WITH a real database
public class UserRepositoryIntegrationTest {

    private InMemoryUserDatabase userDatabase;
    private UserRegistrationService userRegistrationService;

    // Runs BEFORE each test — creates a clean state so tests don't interfere
    @BeforeEach
    void setUpFreshEnvironment() {
        userDatabase = new InMemoryUserDatabase();
        // We wire up the real service with the real database — no mocks!
        userRegistrationService = new UserRegistrationService(userDatabase);
    }

    // Runs AFTER each test — cleans up to prevent test pollution
    @AfterEach
    void tearDown() {
        userDatabase.clearAll();
    }

    @Test
    @DisplayName("Registering a user saves them to the database and returns a valid ID")
    void testUserRegistrationPersistsToDatabase() {
        // ACT — register a new user through the service layer
        int newUserId = userRegistrationService.registerUser("alice_smith");

        // ASSERT — the ID should be a positive integer (valid database ID)
        assertTrue(newUserId > 0, "Database should assign a positive ID");

        // ASSERT — we can retrieve the same user back from the database
        String retrievedUserName = userRegistrationService.getUserById(newUserId);
        assertEquals("alice_smith", retrievedUserName, "Retrieved name must match the registered name");
    }

    @Test
    @DisplayName("Registering multiple users assigns unique IDs to each")
    void testMultipleUsersGetUniqueIds() {
        int aliceId = userRegistrationService.registerUser("alice_smith");
        int bobId   = userRegistrationService.registerUser("bob_jones");

        // The two IDs must be different — IDs are not shared
        assertNotEquals(aliceId, bobId, "Each user must receive a unique ID");

        // Verify each ID retrieves the correct owner
        assertEquals("alice_smith", userRegistrationService.getUserById(aliceId));
        assertEquals("bob_jones",   userRegistrationService.getUserById(bobId));
    }

    @Test
    @DisplayName("Looking up a non-existent user throws a RuntimeException")
    void testLookupOfNonExistentUserThrowsException() {
        int nonExistentUserId = 9999;

        // The service + database together must correctly report missing data
        assertThrows(
            RuntimeException.class,
            () -> userRegistrationService.getUserById(nonExistentUserId),
            "Fetching a missing user ID must throw RuntimeException"
        );
    }
}
Output
Test run finished after 94 ms
[ 3 tests found ]
[ 3 tests started ]
[ 3 tests successful ]
[ 0 tests failed ]
✔ Registering a user saves them to the database and returns a valid ID
✔ Registering multiple users assigns unique IDs to each
✔ Looking up a non-existent user throws a RuntimeException
Watch Out: Test Pollution
Integration tests share real resources like databases. If test A writes data and test B reads it, B's result depends on A running first — which makes tests fragile and order-dependent. Always use @BeforeEach to set up fresh state and @AfterEach to clean up. Each test must be able to run completely alone and pass.
Production Insight
Integration tests catch data contract mismatches that unit tests cannot.
The most common production bug from missing integration tests: field name mismatch between layers.
Rule: every boundary crossing (service→DB, service→API) needs an integration test.
Key Takeaway
Integration tests verify component interactions.
They catch bugs unit tests can't find: data contracts, connection errors, timing issues.
Every boundary crossing must have an integration test.
When to Write an Integration Test
IfCode calls a real database, filesystem, or external service
UseWrite an integration test with a real instance (test container or in-memory equivalent).
IfTwo services communicate over HTTP or messaging
UseWrite an integration test that starts both services (or uses contract tests).
IfCode uses a third-party SDK or library
UseWrite an integration test with the real SDK (or a wiremock if SDK has no side effects).

System, Acceptance & Regression Testing — The Big Picture Checks

Once individual pieces and their connections are verified, three more critical testing types zoom out to look at the whole picture.

System Testing treats the entire application as a black box — the tester doesn't care about the code inside, only whether the complete system behaves correctly end-to-end. A login flow, a full checkout process, a report generation pipeline — these are system test territory. Think of it as the first time your entire spaceship gets switched on and you check all the lights, buttons, and engines together.

User Acceptance Testing (UAT) is where the actual customer or stakeholder confirms the software does what they asked for — not what the developers assumed they asked for. These two things are famously different. UAT is the 'does this solve MY problem?' check, performed by real users or their representatives, not engineers. It's the final gate before software ships to production.

Regression Testing answers a sneaky, critical question: did the new code break something that was working before? Every time you add a feature or fix a bug, you create a risk of breaking existing behaviour. Regression tests are your existing test suite run again after every change. Automation is essential here — manually re-testing every feature after every commit is simply not feasible at scale. This is exactly why companies invest heavily in automated test suites.

RegressionTestSuite.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import static org.junit.jupiter.api.Assertions.*;

// A simple e-commerce Order system — we'll use this to demonstrate
// how regression tests protect existing behaviour when new code ships
class ShoppingCart {
    private double totalPrice = 0.0;
    private int itemCount = 0;

    // Adds an item to the cart
    public void addItem(String itemName, double itemPrice, int quantity) {
        if (itemPrice < 0) throw new IllegalArgumentException("Price cannot be negative");
        if (quantity < 1) throw new IllegalArgumentException("Quantity must be at least 1");
        totalPrice += itemPrice * quantity;
        itemCount  += quantity;
    }

    // Applies a percentage discount (e.g. 10 means 10% off)
    public void applyDiscountPercent(double discountPercent) {
        if (discountPercent < 0 || discountPercent > 100) {
            throw new IllegalArgumentException("Discount must be between 0 and 100");
        }
        totalPrice = totalPrice * (1 - discountPercent / 100);
    }

    // NEW FEATURE ADDED: free shipping threshold
    // Imagine a developer added this — regression tests make sure
    // the discount and total logic still work correctly alongside it
    public boolean qualifiesForFreeShipping() {
        return totalPrice >= 50.0;
    }

    public double getTotalPrice() { return totalPrice; }
    public int    getItemCount()  { return itemCount;  }
}

// @Tag("regression") marks these tests so CI pipelines can run
// this specific group after every code change
@Tag("regression")
public class RegressionTestSuite {

    @Test
    @DisplayName("[REGRESSION] Cart total calculates correctly after adding multiple items")
    void testCartTotalAfterAddingItems() {
        ShoppingCart cart = new ShoppingCart();

        cart.addItem("Java Programming Book", 29.99, 1);
        cart.addItem("USB-C Cable",            9.99, 2);

        // 29.99 + (9.99 * 2) = 29.99 + 19.98 = 49.97
        assertEquals(49.97, cart.getTotalPrice(), 0.001,
            "Total must be sum of all item prices times quantities");
        assertEquals(3, cart.getItemCount(), "Item count must reflect total quantity added");
    }

    @Test
    @DisplayName("[REGRESSION] 10% discount correctly reduces the cart total")
    void testDiscountReducesTotalCorrectly() {
        ShoppingCart cart = new ShoppingCart();
        cart.addItem("Mechanical Keyboard", 100.00, 1);

        cart.applyDiscountPercent(10); // 10% off £100 = £90

        assertEquals(90.0, cart.getTotalPrice(), 0.001,
            "10% discount on £100 should give £90 total");
    }

    @Test
    @DisplayName("[REGRESSION] Adding item with negative price throws exception")
    void testNegativePriceIsRejected() {
        ShoppingCart cart = new ShoppingCart();

        // This behaviour was working before the new feature was added.
        // The regression test confirms the NEW code didn't accidentally remove
        // this validation.
        assertThrows(
            IllegalArgumentException.class,
            () -> cart.addItem("Broken Item", -5.00, 1),
            "Negative price must still throw IllegalArgumentException after new feature added"
        );
    }

    @Test
    @DisplayName("[REGRESSION] New free-shipping feature doesn't break existing discount logic")
    void testFreeShippingAndDiscountCoexist() {
        ShoppingCart cart = new ShoppingCart();
        cart.addItem("Laptop Stand", 60.00, 1);

        // Before discount: qualifies for free shipping (£60 >= £50)
        assertTrue(cart.qualifiesForFreeShipping(), "£60 cart should qualify for free shipping");

        // Apply 20% discount — now £48, just below the threshold
        cart.applyDiscountPercent(20);

        // After discount: should NOT qualify (£48 < £50)
        assertFalse(cart.qualifiesForFreeShipping(),
            "After 20% discount, £60 becomes £48 — should no longer qualify for free shipping");

        // And the total itself must still be calculated correctly
        assertEquals(48.0, cart.getTotalPrice(), 0.001,
            "Discount must still apply correctly after free-shipping feature was introduced");
    }
}
Output
Test run finished after 31 ms
[ 4 tests found ]
[ 4 tests started ]
[ 4 tests successful ]
[ 0 tests failed ]
✔ [REGRESSION] Cart total calculates correctly after adding multiple items
✔ [REGRESSION] 10% discount correctly reduces the cart total
✔ [REGRESSION] Adding item with negative price throws exception
✔ [REGRESSION] New free-shipping feature doesn't break existing discount logic
Interview Gold: The Testing Pyramid
Interviewers love asking about the Testing Pyramid — the idea that you should have LOTS of unit tests (fast, cheap), FEWER integration tests (slower, more setup), and VERY FEW end-to-end/system tests (slowest, most expensive). An inverted pyramid — too many slow end-to-end tests, too few unit tests — is called an 'ice cream cone anti-pattern' and is a sign of an unhealthy test suite. Knowing this concept by name will impress any interviewer.
Production Insight
System tests catch workflow bugs that no lower-level test can.
Acceptance tests prevent the 'we built what you asked, not what you need' disaster.
Regression tests are your insurance policy — they make refactoring safe.
Key Takeaway
System tests verify the whole app works end-to-end.
Acceptance tests verify it solves the user's problem.
Regression tests verify nothing already working broke.
All three are needed; none replace the others.
When to Use Each Big-Picture Test Type
IfNeed to verify a complete user workflow (e.g., login → search → checkout)
UseRun system tests (often automated with Selenium or Playwright).
IfNeed to confirm the software matches business requirements
UseRun acceptance tests with real stakeholders (UAT). These are often manual.
IfAny code change is being deployed
UseRun the full regression suite automatically. Every single change.

Performance Testing — Will It Hold Up When Millions Show Up?

Performance testing answers a different question: not just 'does it work?' but 'does it work fast enough under real load?' A system that passes all functional tests can still fail in production when 10,000 users hit it at once. Performance testing uncovers bottlenecks, memory leaks, and scalability limits before they take down your service.

There are several flavours: Load Testing simulates expected traffic to see if response times stay within SLAs. Stress Testing pushes beyond normal limits to find the breaking point. Soak Testing runs the system under load for hours or days to find memory leaks or resource exhaustion that only appear over time.

In Java, JMeter or Gatling are popular tools. But even a simple JUnit test with a loop can expose performance regressions. The key is to establish a baseline and compare each build — a 20% increase in response time is a red flag even if all functional tests pass.

PerformanceRegressionTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;

class SearchService {
    // Simulates a slow search that might degrade under load
    public String search(String query) {
        // Simulate network latency
        try { Thread.sleep(20); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        if (query == null || query.isBlank()) throw new IllegalArgumentException();
        return "Results for " + query;
    }
}

public class PerformanceRegressionTest {

    private SearchService searchService = new SearchService();

    @Test
    @DisplayName("[PERF] Search should complete within 200ms under normal conditions")
    void testSearchPerformanceBaseline() {
        assertTimeout(Duration.ofMillis(200), () -> {
            String result = searchService.search("Java testing");
            assertNotNull(result);
        });
    }

    @Test
    @DisplayName("[PERF] Concurrency test: 50 parallel searches should all complete")
    void testConcurrentSearches() throws InterruptedException {
        int threadCount = 50;
        Thread[] threads = new Thread[threadCount];
        boolean[] results = new boolean[threadCount];
        for (int i = 0; i < threadCount; i++) {
            int index = i;
            threads[i] = new Thread(() -> {
                try {
                    searchService.search("concurrent test " + index);
                    results[index] = true;
                } catch (Exception e) {
                    results[index] = false;
                }
            });
            threads[i].start();
        }
        for (Thread t : threads) {
            t.join(5000); // wait up to 5 seconds
        }
        for (int i = 0; i < threadCount; i++) {
            assertTrue(results[i], "Thread " + i + " failed to complete");
        }
    }
}
Output
Test run finished after 253 ms
[ 2 tests found ]
[ 2 tests started ]
[ 2 tests successful ]
[ 0 tests failed ]
✔ [PERF] Search should complete within 200ms under normal conditions
✔ [PERF] Concurrency test: 50 parallel searches should all complete
Mental Model: Performance Testing as Loaded Elevator
  • Functional tests check the elevator doors open and close correctly.
  • Load tests check the elevator still works when 20 people are inside.
  • Stress tests find the maximum occupancy before the cables snap.
  • Soak tests check the elevator doesn't break down after running all day.
Production Insight
Performance test failures are often gradual, not binary.
A query that takes 50ms in dev can take 5 seconds in production with real data.
Rule: set performance baselines early and fail the build if they regress by more than 10%.
Key Takeaway
Performance testing catches what functional tests cannot: speed, scalability, resource leaks.
Load, stress, and soak tests each target different failure modes.
Without performance baselines, you're flying blind under traffic.
Which Performance Test to Run When
IfYou're about to deploy a new feature that touches a hot path (search, checkout, login)
UseRun a load test with expected traffic to catch regressions.
IfThe system has been running for months with no changes to infrastructure
UseRun a soak test for 24 hours to detect memory leaks.
IfYou're planning a marketing campaign expected to double traffic
UseRun a stress test to find the breaking point and plan capacity accordingly.

Security Testing — Can an Attacker Break In?

Security testing is about finding vulnerabilities before attackers do. It's not just about penetration testing (which is expensive and done infrequently). Modern security testing embeds automated checks into the development pipeline: static analysis scans code for common vulnerabilities (SQL injection, XSS), dynamic analysis probes running applications, and dependency scanning checks for known CVEs in libraries.

In practice, you don't need to be a security expert to start. Tools like OWASP ZAP can be integrated into your CI pipeline. But understanding the basic risk categories helps you prioritise: injection flaws (SQL, command) are the most dangerous, broken authentication is the most common, and misconfiguration (default passwords, verbose error messages) is the most embarassing.

The biggest mistake junior engineers make is assuming security testing is someone else's job. In 2026, almost every production breach starts with code that a developer wrote. Security testing is just another testing type — automate it, run it early, and fix findings like any other bug.

SecurityScanTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

// Simulated security checks you can run as unit tests
class SecurityScanner {

    // Checks if a string contains patterns indicative of SQL injection attempts
    public boolean detectSQLInjection(String input) {
        String[] patterns = {"'", "\"", "OR 1=1", "DROP TABLE", "--"};
        for (String pattern : patterns) {
            if (input.toUpperCase().contains(pattern.toUpperCase())) {
                return true;
            }
        }
        return false;
    }

    // Check if a password meets minimum strength requirements
    public boolean isWeakPassword(String password) {
        return password.length() < 8 || password.equalsIgnoreCase("password123");
    }
}

public class SecurityScanTest {

    private SecurityScanner scanner = new SecurityScanner();

    @Test
    @DisplayName("[SEC] Detect basic SQL injection pattern in input")
    void testDetectSQLInjection() {
        assertTrue(scanner.detectSQLInjection("' OR '1'='1"));
        assertFalse(scanner.detectSQLInjection("hello world"));
    }

    @Test
    @DisplayName("[SEC] Block weak passwords during registration")
    void testWeakPasswordDetection() {
        assertTrue(scanner.isWeakPassword("password123"));
        assertTrue(scanner.isWeakPassword("abc"));
        assertFalse(scanner.isWeakPassword("Tr0ub4dor&3"));
    }

    @Test
    @DisplayName("[SEC] Ensure sensitive data is not hardcoded in source")
    void testNoHardcodedSecrets() {
        // In production, use a static analysis tool like FindSecBugs or Sonar
        String sourceCode = "";
        // Simulated check: source should not contain API keys or passwords
        assertFalse(sourceCode.contains("apiKey") && sourceCode.contains("="));
        assertFalse(sourceCode.contains("password"));
    }
}
Output
Test run finished after 12 ms
[ 3 tests found ]
[ 3 tests started ]
[ 3 tests successful ]
[ 0 tests failed ]
✔ [SEC] Detect basic SQL injection pattern in input
✔ [SEC] Block weak passwords during registration
✔ [SEC] Ensure sensitive data is not hardcoded in source
Don't Rely on Unit Tests for Security
Unit tests can catch basic input validation issues, but they cannot replace dedicated security tools. SAST (Static Application Security Testing) tools like SonarQube, and DAST (Dynamic) tools like OWASP ZAP, scan for vulnerabilities that unit tests miss entirely. Integrate them into your CI pipeline. A single false positive from a scanner is better than a production breach.
Production Insight
Security scans should run on every pull request, not just before release.
The most common security bugs in production: unsanitized inputs, hardcoded secrets, outdated libraries.
Rule: automate dependency scanning (OWASP Dependency-Check) in your CI — it catches CVEs you didn't know existed.
Key Takeaway
Security testing is every developer's responsibility, not just the security team's.
Automated scanning catches the OWASP Top 10 before they reach production.
A security bug is still a bug — fix it like any other failed test.
Security Testing Priority Matrix
IfCode handles user input directly (forms, search, API params)
UseAdd SAST check for injection flaws. 100% of inputs must be sanitized or parameterized.
IfCode authenticates users or handles sessions
UseReview authentication logic for common flaws: weak password policies, missing rate limiting, JWT validation issues.
IfCode uses third-party libraries
UseRun dependency vulnerability scan. Pin versions and use Dependabot or similar for automated updates.
● Production incidentPOST-MORTEMseverity: high

The Silent Regression: How a Discount Change Broke the Checkout

Symptom
Customers could add items to cart, but applying the discount coupon caused an infinite redirect loop. The checkout page never loaded.
Assumption
The promo code logic was isolated and well-tested — unit tests for the discount calculator and integration tests for the coupon service passed.
Root cause
The new promotion introduced a recursive call in the pricing engine when a condition matched 'BOGO' items. The regression test suite didn't cover the combined flow of adding multiple items and applying a specific coupon.
Fix
Added a regression test that simulated a full checkout with BOGO items and a discount coupon. Fixed the recursion by flattening the discount evaluation into a single pass.
Key lesson
  • Unit and integration tests passing doesn't mean the system works as a whole.
  • Always include regression tests that exercise complete happy-path workflows, especially when adding conditional business logic.
  • If your regression suite doesn't cover the full checkout flow, you're shipping blind.
Production debug guideSymptom → Action guide for test failures that make CI unreliable4 entries
Symptom · 01
Test passes locally but fails on CI consistently
Fix
Check for environment differences: timezone, locale, file encoding, database state. Pin exact versions in Docker image.
Symptom · 02
Test fails intermittently with no code change
Fix
Look for shared mutable state between tests. Use @BeforeEach to reset all static/singleton instances.
Symptom · 03
Integration test fails 10% of the time with connection timeout
Fix
Add retry logic with exponential backoff in test setup. Increase test container startup timeout.
Symptom · 04
Test fails when run in a specific order
Fix
Enable random test execution in CI. Break test dependencies by cleaning up resources in @AfterEach.
★ Quick Debug Cheat Sheet: Common Test FailuresImmediate steps when your tests fail in ways that don't make sense
Flaky unit test — passes sometimes, fails sometimes
Immediate action
Run the test 100 times in a loop. Look for time-dependent or random values.
Commands
for i in {1..100}; do mvn test -Dtest=FailingTest; done | grep -E '(Tests run|FAILURE)'
Add @RepeatedTest(100) in JUnit 5 to reproduce deterministically
Fix now
Remove shared static state or add Thread.sleep after async operations (but prefer CountDownLatch)
Integration test fails with 'connection refused'+
Immediate action
Check if test database container is running and port mappings are correct.
Commands
docker ps | grep test-db
docker logs test-db-container --tail 50
Fix now
Add depends_on with healthcheck condition in docker-compose.yml
Test suite takes >30 minutes and blocks deployment+
Immediate action
Identify the slowest tests using build tool profiles.
Commands
mvn test -Djava.util.logging.config.file=logging.properties -q 2>&1 | grep -E 'Tests run:' | tail -1
Use JUnit @Tag to separate fast unit tests from slow integration tests; run them in parallel
Fix now
Move slow tests to a separate CI job that runs in parallel with the main pipeline
Testing Types Compared
AspectUnit TestingIntegration TestingSystem TestingAcceptance TestingRegression TestingPerformance TestingSecurity Testing
What it testsOne method or function in isolationTwo or more components working togetherThe complete application end-to-endWhether the software meets user requirementsWhether new changes broke existing featuresSpeed, scalability, resource usageVulnerabilities, misconfigurations, weak controls
Who runs itDeveloperDeveloper or QA engineerQA engineerClient or business stakeholderDeveloper or CI/CD pipeline (automated)QA / Performance EngineerDeveloper / Security Engineer (automated)
SpeedVery fast (milliseconds)Moderate (seconds)Slow (minutes)Manual — hours or daysDepends on suite sizeMinutes to hoursFast (static) to slow (dynamic)
When in the processDuring development (constantly)After units are proven to workAfter integration testing passesJust before production releaseAfter every code change or deploymentBefore release, after code changesFrom dev to production (continuous)
Catches what bugsLogic errors in individual methodsBroken connections between componentsFull workflow failuresMisunderstood requirementsUnintended side-effects of new codeSlow response, memory leaks, breaking pointSQL injection, XSS, authentication bypass, CVEs
Typical tools (Java)JUnit 5, TestNGJUnit 5 + Spring Test, H2, TestcontainersSelenium, Playwright, CypressNo standard tool — often manual scriptsThe full automated test suite on a CI triggerJMeter, Gatling, k6, JUnit with timingOWASP ZAP, SonarQube, Snyk, Dependency-Check
Requires real database?No — dependencies are mockedYes — real or realistic test databaseYes — staging environmentYes — production-like environmentDepends on which tests are in the suiteYes — staging or prod-like environmentYes — for dynamic analysis

Key takeaways

1
Unit tests check the smallest piece of code in isolation
one method, one behaviour, one test. They're the foundation: fast, cheap, and catch logic bugs immediately when you change code.
2
Integration tests check that two or more components communicate correctly. They catch an entire class of bugs
mismatched data formats, broken database queries, API contract mismatches — that unit tests structurally cannot find.
3
System testing treats the full application as a black box and verifies complete workflows. Acceptance testing (UAT) then confirms that what was built is actually what the user asked for
these are different checks and both matter.
4
Regression testing is your automated safety net against the most common cause of production incidents
a developer fixing one bug and accidentally breaking three features that were already working. Run your full suite on every commit.
5
Performance testing (load, stress, soak) ensures your system can handle real traffic. Security testing (SAST, DAST, dependency scanning) ensures attackers can't exploit your code. Both are critical in modern pipelines.

Common mistakes to avoid

5 patterns
×

Writing unit tests that test multiple behaviours in one test method

Symptom
When the test fails, you can't tell which of the five things you checked actually broke. Debugging takes far longer than it should.
Fix
One test method = one behaviour. If your test method name needs the word 'and' in it (e.g., 'testAddsItemAndCalculatesTotal'), split it into two separate test methods immediately.
×

Skipping integration tests because unit tests all pass

Symptom
All 500 unit tests go green, then the app crashes in staging because the service sends JSON with a field named 'user_id' but the database mapper expects 'userId'.
Fix
Unit tests prove each component works in isolation; they cannot prove components work together. Always include integration tests for any code path that crosses a boundary (service→database, service→external API, controller→service).
×

Letting tests share state

Symptom
Tests pass when run in alphabetical order but fail randomly when run in a different order, making the CI pipeline unreliable and making developers distrust the test suite.
Fix
Use @BeforeEach to create fresh objects and @AfterEach to clean up resources before every single test. Each test must be a self-contained world that doesn't depend on any other test having run before it.
×

Treating performance testing as a one-time activity

Symptom
The app was fast at launch, but after six months of feature additions, response times doubled. No one noticed until users complained.
Fix
Integrate performance regression tests into CI. Set response time thresholds and treat a 20% degradation as a build failure.
×

Assuming security testing is only for penetration testers

Symptom
A developer hardcodes an API key in source code. The key is committed and later exposed in a public repository.
Fix
Automate security scanning at every stage: static analysis on commit, dependency scanning in PRs, and dynamic analysis on staging environments.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What's the difference between unit testing and integration testing, and ...
Q02SENIOR
Explain the Testing Pyramid. What happens to a test suite when it's inve...
Q03SENIOR
What is regression testing and why is it critical in a CI/CD pipeline? I...
Q04SENIOR
How do you decide which types of performance testing to run and when?
Q05SENIOR
What's the role of automated security testing in a modern DevSecOps pipe...
Q01 of 05JUNIOR

What's the difference between unit testing and integration testing, and why do we need both? Give a concrete example where unit tests pass but integration tests would fail.

ANSWER
Unit testing verifies a single component in isolation (with mocks). Integration testing verifies that two or more real components work together. You need both because unit tests cannot catch contract mismatches. Example: A UserService unit test mocks the database and passes, but the real database returns results with different field names (e.g., 'user_id' vs 'userId'). The integration test with a real database fails, catching the mismatch before production.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between unit testing and integration testing?
02
What is the most important type of software testing?
03
What is the difference between system testing and acceptance testing?
04
How do you implement regression testing in CI/CD?
05
Do I need performance testing for every project?
🔥

That's Software Engineering. Mark it forged?

5 min read · try the examples if you haven't

Previous
Test-Driven Development — TDD
8 / 16 · Software Engineering
Next
Version Control Best Practices