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;
importstatic org.junit.jupiter.api.Assertions.*;
// This is the class we want to testclassCalculator {
// Adds two integers and returns the resultpublicintadd(int firstNumber, int secondNumber) {
return firstNumber + secondNumber;
}
// Divides numerator by denominator// Throws ArithmeticException if denominator is zeropublicdoubledivide(double numerator, double denominator) {
if (denominator == 0) {
thrownewArithmeticException("Cannot divide by zero");
}
return numerator / denominator;
}
// Returns true if a number is evenpublicbooleanisEven(int number) {
return number % 2 == 0;
}
}
// The test class — JUnit discovers methods annotated with @TestpublicclassCalculatorTest {
// Create ONE shared instance of the thing we're testingCalculator calculator = newCalculator();
@Test
@DisplayName("Adding two positive numbers returns their sum")
voidtestAdditionOfTwoPositiveNumbers() {
// ARRANGE — set up the inputsint firstNumber = 7;
int secondNumber = 3;
// ACT — call the method under testint result = calculator.add(firstNumber, secondNumber);
// ASSERT — verify the result is what we expectassertEquals(10, result, "7 + 3 should equal 10");
}
@Test
@DisplayName("Adding a positive and a negative number works correctly")
voidtestAdditionWithNegativeNumber() {
int result = calculator.add(10, -4);
// Negative numbers are a classic edge case — always test themassertEquals(6, result, "10 + (-4) should equal 6");
}
@Test
@DisplayName("Dividing by zero throws an ArithmeticException")
voidtestDivisionByZeroThrowsException() {
// assertThrows checks that calling this code DOES throw the expected exception// If it does NOT throw, the test FAILSassertThrows(
ArithmeticException.class,
() -> calculator.divide(10, 0),
"Dividing by zero must throw ArithmeticException"
);
}
@Test
@DisplayName("Even number check returns true for 4")
voidtestIsEvenReturnsTrueForEvenNumber() {
assertTrue(calculator.isEven(4), "4 is even, so isEven should return true");
}
@Test
@DisplayName("Even number check returns false for 7")
voidtestIsEvenReturnsFalseForOddNumber() {
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;
importstatic 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)classInMemoryUserDatabase {
privatefinalMap<Integer, String> userStore = newHashMap<>();
privateint nextId = 1;
// Saves a user and returns the auto-generated ID (like a real DB would)publicintsaveUser(String userName) {
int assignedId = nextId++;
userStore.put(assignedId, userName);
return assignedId;
}
// Finds a user by ID — returns Optional to handle "not found" cleanlypublicOptional<String> findUserById(int userId) {
returnOptional.ofNullable(userStore.get(userId));
}
// Clears all data — useful for resetting state between testspublicvoidclearAll() {
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.classUserRegistrationService {
privatefinalInMemoryUserDatabase userDatabase;
// The database is injected — this is dependency injection in actionpublicUserRegistrationService(InMemoryUserDatabase userDatabase) {
this.userDatabase = userDatabase;
}
// Registers a new user after basic validationpublicintregisterUser(String userName) {
if (userName == null || userName.isBlank()) {
thrownewIllegalArgumentException("Username cannot be empty");
}
// Delegates to the database — this is the integration point under testreturn userDatabase.saveUser(userName.trim());
}
// Looks up a user by their IDpublicStringgetUserById(int userId) {
return userDatabase.findUserById(userId)
.orElseThrow(() -> newRuntimeException("User with ID " + userId + " not found"));
}
}
// Integration test — testing UserRegistrationService WITH a real databasepublicclassUserRepositoryIntegrationTest {
privateInMemoryUserDatabase userDatabase;
privateUserRegistrationService userRegistrationService;
// Runs BEFORE each test — creates a clean state so tests don't interfere
@BeforeEachvoidsetUpFreshEnvironment() {
userDatabase = newInMemoryUserDatabase();
// We wire up the real service with the real database — no mocks!
userRegistrationService = newUserRegistrationService(userDatabase);
}
// Runs AFTER each test — cleans up to prevent test pollution
@AfterEachvoidtearDown() {
userDatabase.clearAll();
}
@Test
@DisplayName("Registering a user saves them to the database and returns a valid ID")
voidtestUserRegistrationPersistsToDatabase() {
// ACT — register a new user through the service layerint 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 databaseString 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")
voidtestMultipleUsersGetUniqueIds() {
int aliceId = userRegistrationService.registerUser("alice_smith");
int bobId = userRegistrationService.registerUser("bob_jones");
// The two IDs must be different — IDs are not sharedassertNotEquals(aliceId, bobId, "Each user must receive a unique ID");
// Verify each ID retrieves the correct ownerassertEquals("alice_smith", userRegistrationService.getUserById(aliceId));
assertEquals("bob_jones", userRegistrationService.getUserById(bobId));
}
@Test
@DisplayName("Looking up a non-existent user throws a RuntimeException")
voidtestLookupOfNonExistentUserThrowsException() {
int nonExistentUserId = 9999;
// The service + database together must correctly report missing dataassertThrows(
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;
importstatic 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 shipsclassShoppingCart {
privatedouble totalPrice = 0.0;
privateint itemCount = 0;
// Adds an item to the cartpublicvoidaddItem(String itemName, double itemPrice, int quantity) {
if (itemPrice < 0) thrownewIllegalArgumentException("Price cannot be negative");
if (quantity < 1) thrownewIllegalArgumentException("Quantity must be at least 1");
totalPrice += itemPrice * quantity;
itemCount += quantity;
}
// Applies a percentage discount (e.g. 10 means 10% off)publicvoidapplyDiscountPercent(double discountPercent) {
if (discountPercent < 0 || discountPercent > 100) {
thrownewIllegalArgumentException("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 itpublicbooleanqualifiesForFreeShipping() {
return totalPrice >= 50.0;
}
publicdoublegetTotalPrice() { return totalPrice; }
publicintgetItemCount() { return itemCount; }
}
// @Tag("regression") marks these tests so CI pipelines can run// this specific group after every code change
@Tag("regression")
publicclassRegressionTestSuite {
@Test
@DisplayName("[REGRESSION] Cart total calculates correctly after adding multiple items")
voidtestCartTotalAfterAddingItems() {
ShoppingCart cart = newShoppingCart();
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.97assertEquals(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")
voidtestDiscountReducesTotalCorrectly() {
ShoppingCart cart = newShoppingCart();
cart.addItem("Mechanical Keyboard", 100.00, 1);
cart.applyDiscountPercent(10); // 10% off £100 = £90assertEquals(90.0, cart.getTotalPrice(), 0.001,
"10% discount on £100 should give £90 total");
}
@Test
@DisplayName("[REGRESSION] Adding item with negative price throws exception")
voidtestNegativePriceIsRejected() {
ShoppingCart cart = newShoppingCart();
// 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")
voidtestFreeShippingAndDiscountCoexist() {
ShoppingCart cart = newShoppingCart();
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 correctlyassertEquals(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;
importstatic org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
classSearchService {
// Simulates a slow search that might degrade under loadpublicStringsearch(String query) {
// Simulate network latencytry { Thread.sleep(20); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
if (query == null || query.isBlank()) thrownewIllegalArgumentException();
return"Results for " + query;
}
}
publicclassPerformanceRegressionTest {
privateSearchService searchService = newSearchService();
@Test
@DisplayName("[PERF] Search should complete within 200ms under normal conditions")
voidtestSearchPerformanceBaseline() {
assertTimeout(Duration.ofMillis(200), () -> {
String result = searchService.search("Java testing");
assertNotNull(result);
});
}
@Test
@DisplayName("[PERF] Concurrency test: 50 parallel searches should all complete")
voidtestConcurrentSearches() throwsInterruptedException {
int threadCount = 50;
Thread[] threads = newThread[threadCount];
boolean[] results = newboolean[threadCount];
for (int i = 0; i < threadCount; i++) {
int index = i;
threads[i] = newThread(() -> {
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%.
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;
importstatic org.junit.jupiter.api.Assertions.*;
// Simulated security checks you can run as unit testsclassSecurityScanner {
// Checks if a string contains patterns indicative of SQL injection attemptspublicbooleandetectSQLInjection(String input) {
String[] patterns = {"'", "\"", "OR 1=1", "DROP TABLE", "--"};
for (String pattern : patterns) {
if (input.toUpperCase().contains(pattern.toUpperCase())) {
returntrue;
}
}
returnfalse;
}
// Check if a password meets minimum strength requirementspublicbooleanisWeakPassword(String password) {
return password.length() < 8 || password.equalsIgnoreCase("password123");
}
}
publicclassSecurityScanTest {
privateSecurityScanner scanner = newSecurityScanner();
@Test
@DisplayName("[SEC] Detect basic SQL injection pattern in input")
voidtestDetectSQLInjection() {
assertTrue(scanner.detectSQLInjection("' OR '1'='1"));
assertFalse(scanner.detectSQLInjection("hello world"));
}
@Test
@DisplayName("[SEC] Block weak passwords during registration")
voidtestWeakPasswordDetection() {
assertTrue(scanner.isWeakPassword("password123"));
assertTrue(scanner.isWeakPassword("abc"));
assertFalse(scanner.isWeakPassword("Tr0ub4dor&3"));
}
@Test
@DisplayName("[SEC] Ensure sensitive data is not hardcoded in source")
voidtestNoHardcodedSecrets() {
// In production, use a static analysis tool like FindSecBugs or SonarString sourceCode = "";
// Simulated check: source should not contain API keys or passwordsassertFalse(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.
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
Aspect
Unit Testing
Integration Testing
System Testing
Acceptance Testing
Regression Testing
Performance Testing
Security Testing
What it tests
One method or function in isolation
Two or more components working together
The complete application end-to-end
Whether the software meets user requirements
Whether new changes broke existing features
Speed, scalability, resource usage
Vulnerabilities, misconfigurations, weak controls
Who runs it
Developer
Developer or QA engineer
QA engineer
Client or business stakeholder
Developer or CI/CD pipeline (automated)
QA / Performance Engineer
Developer / Security Engineer (automated)
Speed
Very fast (milliseconds)
Moderate (seconds)
Slow (minutes)
Manual — hours or days
Depends on suite size
Minutes to hours
Fast (static) to slow (dynamic)
When in the process
During development (constantly)
After units are proven to work
After integration testing passes
Just before production release
After every code change or deployment
Before release, after code changes
From dev to production (continuous)
Catches what bugs
Logic errors in individual methods
Broken connections between components
Full workflow failures
Misunderstood requirements
Unintended side-effects of new code
Slow response, memory leaks, breaking point
SQL injection, XSS, authentication bypass, CVEs
Typical tools (Java)
JUnit 5, TestNG
JUnit 5 + Spring Test, H2, Testcontainers
Selenium, Playwright, Cypress
No standard tool — often manual scripts
The full automated test suite on a CI trigger
JMeter, Gatling, k6, JUnit with timing
OWASP ZAP, SonarQube, Snyk, Dependency-Check
Requires real database?
No — dependencies are mocked
Yes — real or realistic test database
Yes — staging environment
Yes — production-like environment
Depends on which tests are in the suite
Yes — staging or prod-like environment
Yes — 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.
Q02 of 05SENIOR
Explain the Testing Pyramid. What happens to a test suite when it's inverted — too many end-to-end tests and too few unit tests? What real problems does that cause?
ANSWER
The Testing Pyramid recommends many fast unit tests at the base, fewer slower integration tests in the middle, and very few slow end-to-end tests at the top. An inverted pyramid (too many end-to-end tests) causes slow CI pipelines (hours to run), flaky tests (end-to-end tests are more susceptible to environment issues), and poor diagnostic granularity (when an end-to-end test fails, it's unclear which component caused the failure). Teams with inverted pyramids often skip running tests before deployments, leading to production bugs.
Q03 of 05SENIOR
What is regression testing and why is it critical in a CI/CD pipeline? If you had to explain to a non-technical manager why regression testing is worth the investment, what would you say?
ANSWER
Regression testing ensures that new code changes don't break existing functionality. In CI/CD, it's critical because deployments happen frequently and manually retesting everything is impossible. To a non-technical manager: 'Every time we add a feature or fix a bug, we risk breaking something that used to work. Regression tests are an automated safety net that catches those breaks within minutes, before they reach customers. Without it, we risk a production outage that costs far more than the test suite ever did.'
Q04 of 05SENIOR
How do you decide which types of performance testing to run and when?
ANSWER
It depends on the risk profile of the change. For a new feature on a critical hot path (search, checkout), run a load test to verify response times. For infrastructure changes or long-running services, run a soak test to detect memory leaks. Before expected traffic spikes (e.g., Black Friday), run a stress test to find the breaking point. In all cases, establish a baseline and compare against it automatically in CI.
Q05 of 05SENIOR
What's the role of automated security testing in a modern DevSecOps pipeline?
ANSWER
Automated security testing shifts security left — catching vulnerabilities early instead of waiting for a penetration test before release. SAST tools scan source code for injection flaws, XSS, and hardcoded secrets. Dependency scanning identifies known CVEs in third-party libraries. DAST tools probe running applications. These should be non-blocking warnings in development but gating in production deployment pipelines. They reduce the attack surface significantly without requiring a dedicated security team to review every line of code.
01
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.
JUNIOR
02
Explain the Testing Pyramid. What happens to a test suite when it's inverted — too many end-to-end tests and too few unit tests? What real problems does that cause?
SENIOR
03
What is regression testing and why is it critical in a CI/CD pipeline? If you had to explain to a non-technical manager why regression testing is worth the investment, what would you say?
SENIOR
04
How do you decide which types of performance testing to run and when?
SENIOR
05
What's the role of automated security testing in a modern DevSecOps pipeline?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
What is the difference between unit testing and integration testing?
Unit testing checks a single method or function completely in isolation — all dependencies are replaced with mocks. Integration testing checks that two or more real components work correctly when connected. You need both: unit tests prove the pieces work, integration tests prove the pieces fit together. A unit test cannot catch a bug where your service sends data in a format your database doesn't expect — only an integration test can.
Was this helpful?
02
What is the most important type of software testing?
There's no single 'most important' type — they form a layered defence. That said, unit testing is the foundation because it's the fastest feedback loop you have. If your unit tests are strong, integration and system tests become much cheaper to write and maintain. Most experienced teams follow the Testing Pyramid: many unit tests, fewer integration tests, very few end-to-end tests.
Was this helpful?
03
What is the difference between system testing and acceptance testing?
System testing is done by QA engineers who verify that the complete technical system works correctly end-to-end — they're checking against the specification. Acceptance testing (UAT) is done by the actual client or end users, who verify that the software solves their real-world problem — they're checking against their expectations. Software can pass system testing and still fail UAT if the requirements were misunderstood during development.
Was this helpful?
04
How do you implement regression testing in CI/CD?
Regression tests are typically the entire automated test suite (unit + integration + system). In CI/CD, they run on every push to the main branch and on every pull request. Tools like Jenkins, GitHub Actions, or GitLab CI can trigger them automatically. Use test tags to parallelise execution, and set thresholds for performance regressions. If a regression test fails, the pipeline stops, preventing the broken code from reaching production.
Was this helpful?
05
Do I need performance testing for every project?
Not every project needs full-scale performance testing, but baseline checks are cheap and valuable. For a small internal tool with 10 users, a simple load test with JMeter or even a JUnit timing assertion is enough. For any service that faces external users or has SLAs, performance testing is essential. Even a single test that measures response time per build can catch a regression before it becomes a production incident.