10 · Project — Manual Test Plan + Automated Suite¶
This project combines everything in Level 1. You will act as the sole QA engineer for a small web application: write a real test plan, design manual test cases using formal techniques, build a traceability matrix, raise a defect report, and then automate the repeatable parts with JUnit 5, Selenium and TestNG.
Application under test: https://the-internet.herokuapp.com/login — a
public practice site with a deliberately simple login flow.
- Valid credentials: username
tomsmith, passwordSuperSecretPassword! - Invalid username → flash message "Your username is invalid!"
- Invalid password → flash message "Your password is invalid!"
- Success → redirect to
/secure, flash "You logged into a secure area!", a Logout button appears
Requirements to test against¶
| Req ID | Requirement |
|---|---|
| REQ-01 | The login page displays a Username field, a Password field and a Login button |
| REQ-02 | Valid credentials redirect the user to /secure |
| REQ-03 | A successful login displays the message "You logged into a secure area!" |
| REQ-04 | A successful login displays a Logout button |
| REQ-05 | An invalid username displays "Your username is invalid!" and keeps the user on /login |
| REQ-06 | An invalid password displays "Your password is invalid!" and keeps the user on /login |
| REQ-07 | Submitting empty fields is rejected with an error message |
| REQ-08 | The password field masks the characters entered |
| REQ-09 | Clicking Logout returns the user to /login with the message "You logged out of the secure area!" |
| REQ-10 | Navigating directly to /secure without logging in is rejected |
Deliverable 1 — Test plan¶
Write test-plan.md covering the eight sections from Module 02, section 6.
A worked skeleton:
# Test Plan — Login Feature
## 1. Scope
In scope: the /login page, authentication, the /secure page, logout.
Out of scope: registration, password reset, any other page on the site,
performance and load testing.
## 2. Test approach
Manual: exploratory session on the login form; usability and UI checks;
boundary and negative cases requiring human judgement.
Automated: the regression subset -- all four credential combinations,
the logout flow, and direct-URL access to /secure.
Levels: system testing (black box, through the UI).
Types: functional, plus basic security (session protection) and
usability observations.
## 3. Test environment
Browser: Chrome 126 (headless for automation, headed for exploratory)
OS: <your OS>
Java 21, Maven 3.9, JUnit 5.10.2, Selenium 4.23.0, TestNG 7.10.2
Base URL: https://the-internet.herokuapp.com
## 4. Entry & exit criteria
Entry: application reachable; smoke test (page loads, form renders) passes.
Exit: 100% of planned cases executed; zero open S1/S2 defects;
all automated tests green in a clean `mvn clean test` run.
## 5. Schedule
Test design ......... 1 day
Manual execution .... 0.5 day
Automation build .... 1.5 days
Regression run ...... 0.5 day
## 6. Roles
QA Engineer (you): design, execution, automation, defect reporting.
## 7. Risks & mitigations
- Public practice site may be temporarily unavailable
-> mitigate: retry, and mark blocked rather than failed.
- Site content may change without notice
-> mitigate: locators anchored on stable ids, not layout.
## 8. Deliverables
test-plan.md, test-cases.md, rtm.md, defect-report.md,
automated suite under src/test/java, testng.xml, execution summary.
Deliverable 2 — Manual test cases¶
Write test-cases.md with at least 12 test cases in the full template
from Module 02. Here are two worked examples to set the standard; you write
the rest.
| Field | Value |
|---|---|
| Test Case ID | TC_LOGIN_001 |
| Module | Authentication |
| Title | Verify successful login with valid credentials |
| Requirement ID | REQ-02, REQ-03, REQ-04 |
| Priority | P1 |
| Type | Positive |
| Preconditions | Browser is open; user is not logged in; no session cookie present |
| Test Data | Username tomsmith · Password SuperSecretPassword! |
| Steps | 1. Navigate to https://the-internet.herokuapp.com/login.2. Enter tomsmith in the Username field.3. Enter SuperSecretPassword! in the Password field.4. Click the Login button. |
| Expected Result | 1. The browser navigates to /secure.2. A green flash message reads "You logged into a secure area!". 3. A Logout button is visible. 4. The heading reads "Secure Area". |
| Status | Not Executed |
| Field | Value |
|---|---|
| Test Case ID | TC_LOGIN_010 |
| Module | Authentication / Session security |
| Title | Verify direct navigation to /secure without authentication is rejected |
| Requirement ID | REQ-10 |
| Priority | P1 |
| Type | Negative / Security |
| Preconditions | No active session; browser cookies cleared |
| Test Data | URL https://the-internet.herokuapp.com/secure |
| Steps | 1. Clear all cookies. 2. Navigate directly to https://the-internet.herokuapp.com/secure. |
| Expected Result | 1. The user is redirected to /login.2. A flash message indicates the page must be logged into. 3. No secure-area content is rendered at any point. |
| Status | Not Executed |
Your 12+ cases must include:
- All four credential combinations (valid/valid, valid/invalid, invalid/valid, empty/empty)
- Boundary cases on the username field — 1 character, a very long value (5,000 characters), leading/trailing whitespace
- Equivalence partitions — special characters, SQL-injection-shaped input
(
' OR '1'='1), Unicode (Zoë,你好, emoji) - REQ-08: the password field masks input (check
getAttribute("type") == "password") - The logout flow (REQ-09)
- Direct-URL access to
/secure(REQ-10) - At least one UI/usability observation
Deliverable 3 — Requirement Traceability Matrix¶
Write rtm.md mapping every requirement to your test case IDs. Every row
must have at least one case, or you have found a coverage gap.
| Req ID | Requirement | Test Case IDs | Automated? | Status |
|---|---|---|---|---|
| REQ-01 | Login form renders | TC_LOGIN_011 | Yes | |
| REQ-02 | Valid login redirects to /secure | TC_LOGIN_001 | Yes | |
| REQ-03 | Success message shown | TC_LOGIN_001 | Yes | |
| REQ-04 | Logout button appears | TC_LOGIN_001 | Yes | |
| REQ-05 | Invalid username error | TC_LOGIN_002 | Yes | |
| REQ-06 | Invalid password error | TC_LOGIN_003 | Yes | |
| REQ-07 | Empty fields rejected | TC_LOGIN_004 | Yes | |
| REQ-08 | Password masked | TC_LOGIN_005 | Yes | |
| REQ-09 | Logout flow | TC_LOGIN_009 | Yes | |
| REQ-10 | /secure protected | TC_LOGIN_010 | Yes |
Deliverable 4 — Automated suite¶
4a. A JUnit 5 unit test¶
First, a pure unit test with no browser. Create
src/main/java/com/example/CredentialValidator.java:
package com.example;
public class CredentialValidator {
/** Client-side pre-check before the form is submitted. */
public boolean isSubmittable(String username, String password) {
if (username == null || password == null) {
throw new IllegalArgumentException("Credentials must not be null");
}
return !username.trim().isEmpty()
&& !password.trim().isEmpty()
&& username.trim().length() <= 100;
}
}
Then src/test/java/com/example/tests/CredentialValidatorTest.java:
package com.example.tests;
import com.example.CredentialValidator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("Credential pre-validation")
class CredentialValidatorTest {
private CredentialValidator validator;
@BeforeEach
void setUp() {
validator = new CredentialValidator();
}
@ParameterizedTest(name = "user=[{0}] pass=[{1}] -> submittable={2}")
@CsvSource({
"tomsmith, SuperSecretPassword!, true", // valid
"'', SuperSecretPassword!, false", // empty username
"tomsmith, '', false", // empty password
"'', '', false", // both empty
"' ', password, false", // whitespace only
"a, p, true" // 1-char boundary
})
void submittability(String username, String password, boolean expected) {
assertEquals(expected, validator.isSubmittable(username, password));
}
@Test
@DisplayName("A username of exactly 100 characters is submittable")
void usernameAtUpperBoundary() {
assertTrue(validator.isSubmittable("u".repeat(100), "pw"));
}
@Test
@DisplayName("A username of 101 characters is rejected")
void usernameAboveUpperBoundary() {
assertFalse(validator.isSubmittable("u".repeat(101), "pw"));
}
@Test
@DisplayName("Null credentials throw IllegalArgumentException")
void nullCredentialsThrow() {
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> validator.isSubmittable(null, "pw"));
assertEquals("Credentials must not be null", ex.getMessage());
}
}
[INFO] Running com.example.tests.CredentialValidatorTest
user=[tomsmith] pass=[SuperSecretPassword!] -> submittable=true PASSED
user=[] pass=[SuperSecretPassword!] -> submittable=false PASSED
user=[tomsmith] pass=[] -> submittable=false PASSED
user=[] pass=[] -> submittable=false PASSED
user=[ ] pass=[password] -> submittable=false PASSED
user=[a] pass=[p] -> submittable=true PASSED
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
4b. The Selenium + TestNG suite¶
src/test/java/com/example/tests/LoginSuiteTest.java:
package com.example.tests;
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.testng.ITestResult;
import org.testng.annotations.*;
import org.testng.asserts.SoftAssert;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import static org.testng.Assert.*;
public class LoginSuiteTest {
private WebDriver driver;
private WebDriverWait wait;
private String baseUrl;
private static final By USERNAME = By.id("username");
private static final By PASSWORD = By.id("password");
private static final By LOGIN_BTN = By.cssSelector("button[type='submit']");
private static final By FLASH = By.id("flash");
private static final By LOGOUT = By.cssSelector("a[href='/logout']");
@Parameters({"browser", "baseUrl"})
@BeforeClass(alwaysRun = true)
public void setUpClass(@Optional("chrome") String browser,
@Optional("https://the-internet.herokuapp.com") String baseUrl) {
this.baseUrl = baseUrl;
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless=new", "--window-size=1920,1080");
driver = new ChromeDriver(options);
wait = new WebDriverWait(driver, Duration.ofSeconds(15));
System.out.println("Suite starting on " + browser + " against " + baseUrl);
}
@BeforeMethod(alwaysRun = true)
public void openLoginPage() {
driver.manage().deleteAllCookies();
driver.get(baseUrl + "/login");
wait.until(ExpectedConditions.visibilityOfElementLocated(USERNAME));
}
private void login(String username, String password) {
WebElement user = driver.findElement(USERNAME);
WebElement pass = driver.findElement(PASSWORD);
user.clear();
pass.clear();
if (!username.isEmpty()) user.sendKeys(username);
if (!password.isEmpty()) pass.sendKeys(password);
driver.findElement(LOGIN_BTN).click();
}
private String flashText() {
return wait.until(ExpectedConditions.visibilityOfElementLocated(FLASH))
.getText().replace("×", "").trim();
}
// ---------- TC_LOGIN_011 -- REQ-01 ----------
@Test(priority = 0, groups = "smoke",
description = "TC_LOGIN_011 -- login form renders all three controls")
public void loginFormRenders() {
SoftAssert softly = new SoftAssert();
softly.assertTrue(driver.findElement(USERNAME).isDisplayed(), "Username field visible");
softly.assertTrue(driver.findElement(PASSWORD).isDisplayed(), "Password field visible");
softly.assertTrue(driver.findElement(LOGIN_BTN).isDisplayed(), "Login button visible");
softly.assertTrue(driver.findElement(LOGIN_BTN).isEnabled(), "Login button enabled");
softly.assertAll();
}
// ---------- TC_LOGIN_001 -- REQ-02, 03, 04 ----------
@Test(priority = 1, groups = "smoke",
description = "TC_LOGIN_001 -- valid credentials log the user in")
public void loginWithValidCredentials() {
login("tomsmith", "SuperSecretPassword!");
SoftAssert softly = new SoftAssert();
softly.assertTrue(driver.getCurrentUrl().endsWith("/secure"),
"REQ-02: should redirect to /secure, was " + driver.getCurrentUrl());
softly.assertTrue(flashText().contains("You logged into a secure area!"),
"REQ-03: success message");
softly.assertTrue(driver.findElement(LOGOUT).isDisplayed(),
"REQ-04: Logout button visible");
softly.assertAll();
}
// ---------- TC_LOGIN_005 -- REQ-08 ----------
@Test(priority = 2, groups = "smoke",
description = "TC_LOGIN_005 -- password field masks input")
public void passwordFieldIsMasked() {
assertEquals(driver.findElement(PASSWORD).getAttribute("type"), "password",
"REQ-08: password input must be of type=password");
}
// ---------- TC_LOGIN_009 -- REQ-09 ----------
@Test(priority = 3, groups = "regression",
dependsOnMethods = "loginWithValidCredentials",
description = "TC_LOGIN_009 -- logout returns the user to /login")
public void logoutFlow() {
login("tomsmith", "SuperSecretPassword!");
driver.findElement(LOGOUT).click();
SoftAssert softly = new SoftAssert();
softly.assertTrue(driver.getCurrentUrl().endsWith("/login"), "Back on /login");
softly.assertTrue(flashText().contains("You logged out of the secure area!"),
"REQ-09: logout message");
softly.assertAll();
}
// ---------- TC_LOGIN_010 -- REQ-10 ----------
@Test(priority = 4, groups = "regression",
description = "TC_LOGIN_010 -- /secure is not reachable without a session")
public void secureAreaBlockedWhenLoggedOut() {
driver.manage().deleteAllCookies();
driver.get(baseUrl + "/secure");
assertTrue(driver.getCurrentUrl().endsWith("/login"),
"REQ-10: unauthenticated access to /secure must redirect to /login");
assertTrue(driver.findElements(LOGOUT).isEmpty(),
"REQ-10: no secure-area content should be rendered");
}
// ---------- TC_LOGIN_002/003/004 -- data-driven negatives ----------
@DataProvider(name = "invalidCredentials")
public Object[][] invalidCredentials() {
return new Object[][] {
{ "wronguser", "SuperSecretPassword!", "Your username is invalid!", "TC_LOGIN_002" },
{ "tomsmith", "WrongPassword1!", "Your password is invalid!", "TC_LOGIN_003" },
{ "", "", "Your username is invalid!", "TC_LOGIN_004" },
{ "TOMSMITH", "SuperSecretPassword!", "Your username is invalid!", "TC_LOGIN_006" },
{ " tomsmith ","SuperSecretPassword!", "Your username is invalid!", "TC_LOGIN_007" },
{ "' OR '1'='1", "anything", "Your username is invalid!", "TC_LOGIN_008" }
};
}
@Test(priority = 5, groups = "regression", dataProvider = "invalidCredentials",
description = "Invalid credential combinations are rejected")
public void invalidCredentialsAreRejected(String username, String password,
String expectedFragment, String caseId) {
login(username, password);
SoftAssert softly = new SoftAssert();
softly.assertTrue(driver.getCurrentUrl().endsWith("/login"),
caseId + ": should remain on /login");
softly.assertTrue(flashText().contains(expectedFragment),
caseId + ": expected message containing '" + expectedFragment
+ "' but got '" + flashText() + "'");
softly.assertTrue(driver.findElements(LOGOUT).isEmpty(),
caseId + ": no Logout button should appear");
softly.assertAll();
}
// ---------- screenshot on failure ----------
@AfterMethod(alwaysRun = true)
public void captureOnFailure(ITestResult result) {
if (result.getStatus() == ITestResult.FAILURE) {
try {
File src = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
Path dest = Path.of("target", "screenshots", result.getName() + ".png");
Files.createDirectories(dest.getParent());
Files.copy(src.toPath(), dest);
System.out.println("FAILED -- screenshot: " + dest.toAbsolutePath());
} catch (Exception e) {
System.out.println("Screenshot failed: " + e.getMessage());
}
}
}
@AfterClass(alwaysRun = true)
public void tearDownClass() {
if (driver != null) driver.quit();
}
}
4c. The suite file¶
testng.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="Login Regression Suite" verbose="1">
<parameter name="baseUrl" value="https://the-internet.herokuapp.com"/>
<test name="Smoke">
<parameter name="browser" value="chrome"/>
<groups><run><include name="smoke"/></run></groups>
<classes><class name="com.example.tests.LoginSuiteTest"/></classes>
</test>
<test name="Full Regression">
<parameter name="browser" value="chrome"/>
<groups><run><include name="smoke"/><include name="regression"/></run></groups>
<classes><class name="com.example.tests.LoginSuiteTest"/></classes>
</test>
</suite>
Running it¶
Suite starting on chrome against https://the-internet.herokuapp.com
===============================================
Login Regression Suite
Total tests run: 17, Passes: 17, Failures: 0, Skips: 0
===============================================
[INFO] Tests run: 27, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
(17 TestNG results — 3 smoke + 3 smoke again in the regression block + 2
regression + 6 data-driven rows + form render — plus the 10 JUnit results
from CredentialValidatorTest.)
Deliverable 5 — Defect report¶
While executing manually you will notice something. Common findings on this
site: the flash message contains a stray × character; the error text
reveals whether a username exists (an account-enumeration issue, exactly the
security concern from Module 02); the username field accepts unlimited
length; there is no rate limiting on failed attempts.
Pick one and write defect-report.md using the full template from Module 04
— title, environment, numbered reproduction steps, expected vs actual,
severity and priority with justification, and reproducibility.
The account-enumeration finding
The site returns "Your username is invalid!" for a non-existent user but "Your password is invalid!" for a real user with the wrong password. An attacker can therefore discover which usernames exist. Severity S3 (nothing is broken functionally), Priority P2 (a real security weakness on the authentication endpoint). This is the kind of finding that comes from thinking about the messages, not from clicking through a script — precisely the tester mindset from Module 01.
Deliverable 6 — Execution summary¶
Write execution-summary.md:
# Test Execution Summary — Login Feature
Build / date: <build> / <date>
Environment: Chrome 126 headless, macOS 14
| Metric | Value |
|---|---|
| Test cases planned | 12 |
| Executed | 12 |
| Passed | 10 |
| Failed | 1 |
| Blocked | 1 |
| Automated | 9 |
| Automation coverage | 75% |
| Defects raised | 1 (S3/P2) |
| Open S1/S2 defects | 0 |
## Requirement coverage
All 10 requirements have at least one test case; see rtm.md.
## Recommendation
Ready to release, with BUG-001 (account enumeration) tracked for the
next sprint. Exit criteria met: zero open S1/S2 defects, 100% of
planned cases executed.
Acceptance checklist¶
- [ ]
test-plan.mdcovers all eight sections, including explicit entry and exit criteria - [ ]
test-cases.mdhas 12+ cases in the full template, with specific, multi-part expected results - [ ] At least 6 cases are negative, boundary or equivalence-partition based
- [ ]
rtm.mdmaps all 10 requirements; no requirement has zero cases - [ ]
defect-report.mduses the full Module 04 template with justified severity and priority - [ ]
CredentialValidatorTestruns under JUnit 5 with at least one@ParameterizedTestcovering boundaries - [ ]
LoginSuiteTestruns under TestNG with@BeforeClass/@BeforeMethod/@AfterClasslifecycle - [ ] At least one
@DataProviderdrives multiple negative cases from one method - [ ]
SoftAssertis used withassertAll()present in every method that creates one - [ ] Explicit waits are used; there is no
Thread.sleepanywhere - [ ] Screenshots are captured on failure into
target/screenshots/ - [ ]
testng.xmldefines separate Smoke and Full Regression blocks using groups - [ ]
mvn clean testcompletes withBUILD SUCCESSfrom a clean checkout - [ ]
execution-summary.mdreports the metrics and a release recommendation
Stretch goals¶
- Break it deliberately. Change one locator to a wrong id, run the suite, and confirm the screenshot and the failure message together let you diagnose the problem in under a minute. If they do not, improve your assertion messages.
- Add
@Test(retryAnalyzer = ...). Implement TestNG'sIRetryAnalyzerto retry a failed test once. Then write two sentences on why retrying is dangerous — it hides real intermittent bugs, the topic of Level 3, Module 09. - Extract the locators. Move the
Byconstants and thelogin()helper into a separateLoginPageclass. You have just invented the Page Object Model — Level 2, Module 01, which formalises exactly this refactor. - Run it in parallel. Add
parallel="methods" thread-count="3"totestng.xmland observe what breaks. Explain why a sharedWebDriverfield makes the suite unsafe, and what aThreadLocal<WebDriver>would fix.
How It Actually Works¶
REQ-10 — "navigating directly to /secure without logging in is
rejected" — is worth understanding mechanically because it's the one
requirement in this list that isn't about the DOM at all: the server holds
a session (typically a cookie-backed session ID mapping to
server-side state saying "this session authenticated"), and /secure's
handler checks that state before rendering anything, redirecting to
/login if it's absent. Your Selenium test never sees that server-side
check directly — it only sees the effect (a redirect, a URL change) —
which is why the correct automated assertion is assertEquals("/login",
driver.getCurrentUrl()) after navigating straight to /secure with a
fresh WebDriver instance (fresh instance matters: @BeforeEach creating
a new ChromeDriver means a new browser profile with no cookies, so you're
verifying the unauthenticated path, not accidentally reusing a session
from a previous test).
This is also where JUnit 5's and TestNG's lifecycle guarantees earn their
keep in a real suite: because each @Test gets setup/teardown around it
(@BeforeEach driver = new ChromeDriver(), @AfterEach driver.quit()),
every test in this project starts from an identical, isolated browser
process with no leftover cookies or local storage from the previous test —
without that isolation, REQ-10 could pass or fail depending on test
execution order, which is exactly the kind of flakiness Module 3.09
(Flaky Test Diagnosis) exists to catch. driver.quit() specifically tells
chromedriver to close every window in the session and then terminate its
own process — skipping it (using driver.close() instead, or nothing at
all) is the single most common cause of orphaned chromedriver/browser
processes silently consuming memory across a long CI run.
Exercise¶
Complete all six deliverables and place them in a single repository with this structure:
login-qa-project/
├── pom.xml
├── testng.xml
├── docs/
│ ├── test-plan.md
│ ├── test-cases.md
│ ├── rtm.md
│ ├── defect-report.md
│ └── execution-summary.md
└── src/
├── main/java/com/example/CredentialValidator.java
└── test/java/com/example/tests/
├── CredentialValidatorTest.java
└── LoginSuiteTest.java
Then answer these four questions in a reflection.md:
- Which of your 12 manual test cases did you choose not to automate, and why? (There should be at least one — if you automated everything, you have probably automated something that did not deserve it.)
- Which technique from Module 05 — EP, BVA, decision tables or state transition — produced the most valuable test case here, and what did it catch that ad-hoc clicking would have missed?
- Your suite currently uses one browser and hard-coded credentials. Name three specific things you would change before this could run nightly on a CI server against three environments.
- Re-read your defect report as if you were the developer receiving it. Could you reproduce the bug from the report alone, with no follow-up questions? If not, fix it.