Project: Test a Login & Cart App
It is time to put the whole course together. In this project you are the tester for Kirana Kart, a small online grocery shop. You will read the requirements, write a short test plan, design test cases with the techniques you learned, automate them, find a real defect, write the bug report, confirm the fix and plan the UI and CI work. This is the kind of work sample that impresses in interviews, so consider rebuilding it in your own Git repository when you finish.
The app is written in Python so you can run everything in your browser. The same steps apply to any web or mobile app.
The requirements
The product owner has written these requirements for the first release:
- R1 A registered user logs in with email and password. Email is not case-sensitive and surrounding spaces are ignored.
- R2 A wrong password shows "Invalid email or password".
- R3 After 3 failed attempts the account is locked, even if the right password is entered next.
- R4 Only a logged-in user can add items. Quantity per add must be 1 to 10. Adding the same item again increases its quantity.
- R5 Users can remove an item from the cart.
- R6 Delivery is free when the subtotal is ₹499 or more; otherwise it costs ₹40.
- R7 Coupon FEST10 gives 10% off orders with a subtotal of ₹500 or more. Codes are not case-sensitive. A coupon can be used only once per order.
While reading, a good tester already asks questions (this is static testing). For example: R6 says free delivery at ₹499 but R7 needs ₹500 for the coupon. Is that intentional? What should happen to the coupon if items are removed and the subtotal falls below ₹500? Write such questions down and ask them before testing starts; you will come back to the second one in the exercise.
Step 1: a one-page test plan
Keep the plan short and specific, as you learned in Test Plans & Strategy:
test_plan: Kirana Kart release 1.0 - login and cart
scope:
in: [login and lockout, cart add/remove, delivery charge, FEST10 coupon]
out: [payment, order history, mobile app]
approach:
design: equivalence partitioning, boundary values, state transition (lockout), negative tests
automation: Python unittest for business rules; Playwright for 2 UI journeys; run in GitHub Actions
risks:
- Wrong discount or delivery charge loses money on every order (high)
- Lockout errors block real customers (high)
entry_criteria: Build installed in the test environment, smoke check passes
exit_criteria: All planned tests run, no open Critical or Major bugs, R1-R7 each covered
deliverables: [test cases, automated suite, bug reports, summary report]Step 2: design the test cases
Each requirement gets at least one test case, and the technique used is noted so reviewers can see why each value was chosen. The traceability is in the first column:
| Req | ID | Test case | Technique | Expected result |
|---|---|---|---|---|
| R1 | TC-01 | Log in with " Priya@Example.com" and the right password | Equivalence (valid), data variation | "Welcome" |
| R2 | TC-02 | Log in with the password in a different case | Negative | "Invalid email or password" |
| R3 | TC-03 | Three wrong passwords, then the right one | State transition | "Account locked" both times |
| R4 | TC-04 | Add an item without logging in | Negative | Refused with an error |
| R4 | TC-05 | Add quantity 0, 1, 10 and 11 | Boundary values | 1 and 10 accepted; 0 and 11 refused |
| R4 | TC-06 | Add the same item twice (1 then 2) | Equivalence | Quantity 3 |
| R5 | TC-07 | Add then remove the only item | Equivalence | Total ₹0 |
| R6 | TC-08 | Subtotal ₹498 and ₹499 | Boundary values | Totals ₹538 and ₹499 |
| R7 | TC-09 | Coupon on a ₹499 subtotal | Boundary values | Refused with the ₹500 message |
| R7 | TC-10 | Coupon "fest10" on ₹640 ghee | Equivalence, case variation | Total ₹576.00 |
| R7 | TC-11 | Apply FEST10 twice on ₹640 | Error guessing (repetition) | Total still ₹576.00 |
| R7 | TC-12 | Unknown code FREE100 | Negative | "Invalid coupon" |
The products are priced so that boundary totals are easy to build: atta ₹289 + toor dal ₹165 + poha ₹44 = ₹498, and with sugar (₹45) instead of poha, ₹499.
Step 3: meet the application
Here is the application code, followed by a quick manual-style smoke check. Run it and read the code: as a tester you do not need to understand every line, but you should know where the rules live.
class KiranaKart:
"""A small login + cart app (the system under test)."""
PRODUCTS = {"atta-5kg": 289, "toor-dal-1kg": 165, "ghee-1l": 640,
"tea-500g": 270, "sugar-1kg": 45, "poha-500g": 44}
COUPONS = {"FEST10": 10} # percent off, orders of Rs 500 or more
MAX_ATTEMPTS = 3
def __init__(self):
self.users = {"priya@example.com": "Kart@2026"}
self.failed = {}
self.locked = set()
self.user = None
self.cart = {}
self.coupon_percent = 0
def login(self, email, password):
email = email.strip().lower()
if email in self.locked:
return "Account locked"
if self.users.get(email) == password:
self.user, self.failed[email] = email, 0
return "Welcome"
self.failed[email] = self.failed.get(email, 0) + 1
if self.failed[email] >= self.MAX_ATTEMPTS:
self.locked.add(email)
return "Account locked"
return "Invalid email or password"
def add(self, sku, qty=1):
if self.user is None:
raise PermissionError("log in first")
if sku not in self.PRODUCTS:
raise KeyError(f"unknown product {sku}")
if not 1 <= qty <= 10:
raise ValueError("quantity must be 1 to 10")
self.cart[sku] = self.cart.get(sku, 0) + qty
def remove(self, sku):
self.cart.pop(sku, None)
def subtotal(self):
return sum(self.PRODUCTS[s] * q for s, q in self.cart.items())
def apply_coupon(self, code):
percent = self.COUPONS.get(code.upper())
if percent is None:
return "Invalid coupon"
if self.subtotal() < 500:
return "Add items worth Rs 500 to use this coupon"
self.coupon_percent += percent # defect: applying twice stacks the discount
return "Coupon applied"
def total(self):
subtotal = self.subtotal()
discount = round(subtotal * self.coupon_percent / 100, 2)
delivery = 0 if subtotal >= 499 or subtotal == 0 else 40
return round(subtotal - discount + delivery, 2)
app = KiranaKart()
print(app.login("Priya@Example.com ", "Kart@2026"))
app.add("ghee-1l")
app.add("tea-500g", 2)
print("Cart:", app.cart, "| subtotal:", app.subtotal())
print(app.apply_coupon("fest10"), "| total:", app.total())Welcome
Cart: {'ghee-1l': 1, 'tea-500g': 2} | subtotal: 1180
Coupon applied | total: 1062.0The smoke check passes: login works with odd capitals and a trailing space, the cart adds up (₹640 + 2 x ₹270 = ₹1,180), and 10% off ₹1,180 with free delivery gives ₹1,062. The build is stable enough to test properly.
Step 4: automate the test cases
Now each test case becomes a test method, grouped into three classes. setUp() gives every test a fresh app (and, for the cart and coupon tests, a logged-in user), and subTest() handles the boundary rows. The test method names start with the test case IDs, so a failure points straight back to the table in Step 2.
class KiranaKart:
"""A small login + cart app (the system under test)."""
PRODUCTS = {"atta-5kg": 289, "toor-dal-1kg": 165, "ghee-1l": 640,
"tea-500g": 270, "sugar-1kg": 45, "poha-500g": 44}
COUPONS = {"FEST10": 10} # percent off, orders of Rs 500 or more
MAX_ATTEMPTS = 3
def __init__(self):
self.users = {"priya@example.com": "Kart@2026"}
self.failed = {}
self.locked = set()
self.user = None
self.cart = {}
self.coupon_percent = 0
def login(self, email, password):
email = email.strip().lower()
if email in self.locked:
return "Account locked"
if self.users.get(email) == password:
self.user, self.failed[email] = email, 0
return "Welcome"
self.failed[email] = self.failed.get(email, 0) + 1
if self.failed[email] >= self.MAX_ATTEMPTS:
self.locked.add(email)
return "Account locked"
return "Invalid email or password"
def add(self, sku, qty=1):
if self.user is None:
raise PermissionError("log in first")
if sku not in self.PRODUCTS:
raise KeyError(f"unknown product {sku}")
if not 1 <= qty <= 10:
raise ValueError("quantity must be 1 to 10")
self.cart[sku] = self.cart.get(sku, 0) + qty
def remove(self, sku):
self.cart.pop(sku, None)
def subtotal(self):
return sum(self.PRODUCTS[s] * q for s, q in self.cart.items())
def apply_coupon(self, code):
percent = self.COUPONS.get(code.upper())
if percent is None:
return "Invalid coupon"
if self.subtotal() < 500:
return "Add items worth Rs 500 to use this coupon"
self.coupon_percent += percent # defect: applying twice stacks the discount
return "Coupon applied"
def total(self):
subtotal = self.subtotal()
discount = round(subtotal * self.coupon_percent / 100, 2)
delivery = 0 if subtotal >= 499 or subtotal == 0 else 40
return round(subtotal - discount + delivery, 2)
import unittest
class TestLogin(unittest.TestCase):
def setUp(self):
self.app = KiranaKart()
def test_tc01_valid_login_ignores_case_and_spaces(self):
self.assertEqual(self.app.login(" Priya@Example.com", "Kart@2026"), "Welcome")
def test_tc02_wrong_password_is_refused(self):
self.assertEqual(self.app.login("priya@example.com", "kart@2026"), "Invalid email or password")
def test_tc03_third_failure_locks_account(self):
for _ in range(2):
self.app.login("priya@example.com", "guess")
self.assertEqual(self.app.login("priya@example.com", "guess"), "Account locked")
self.assertEqual(self.app.login("priya@example.com", "Kart@2026"), "Account locked")
class TestCart(unittest.TestCase):
def setUp(self):
self.app = KiranaKart()
self.app.login("priya@example.com", "Kart@2026")
def test_tc04_add_requires_login(self):
with self.assertRaises(PermissionError):
KiranaKart().add("ghee-1l")
def test_tc05_quantity_boundaries(self):
for qty, allowed in [(0, False), (1, True), (10, True), (11, False)]:
with self.subTest(qty=qty):
app = KiranaKart()
app.login("priya@example.com", "Kart@2026")
if allowed:
app.add("tea-500g", qty)
self.assertEqual(app.cart["tea-500g"], qty)
else:
with self.assertRaises(ValueError):
app.add("tea-500g", qty)
def test_tc06_adding_again_increases_quantity(self):
self.app.add("atta-5kg")
self.app.add("atta-5kg", 2)
self.assertEqual(self.app.cart, {"atta-5kg": 3})
def test_tc07_remove_item(self):
self.app.add("ghee-1l")
self.app.remove("ghee-1l")
self.assertEqual(self.app.total(), 0)
def test_tc08_delivery_boundary(self):
for extra, expected_total in [("poha-500g", 538), ("sugar-1kg", 499)]:
with self.subTest(extra=extra):
app = KiranaKart()
app.login("priya@example.com", "Kart@2026")
app.add("atta-5kg")
app.add("toor-dal-1kg")
app.add(extra)
self.assertEqual(app.total(), expected_total)
class TestCoupon(unittest.TestCase):
def setUp(self):
self.app = KiranaKart()
self.app.login("priya@example.com", "Kart@2026")
def test_tc09_coupon_needs_500(self):
for sku in ["atta-5kg", "toor-dal-1kg", "sugar-1kg"]:
self.app.add(sku)
self.assertEqual(self.app.apply_coupon("FEST10"), "Add items worth Rs 500 to use this coupon")
def test_tc10_coupon_gives_10_percent(self):
self.app.add("ghee-1l")
self.assertEqual(self.app.apply_coupon("fest10"), "Coupon applied")
self.assertEqual(self.app.total(), 576.0)
def test_tc11_coupon_twice_does_not_stack(self):
self.app.add("ghee-1l")
self.app.apply_coupon("FEST10")
self.app.apply_coupon("FEST10")
self.assertEqual(self.app.total(), 576.0)
def test_tc12_unknown_coupon(self):
self.app.add("ghee-1l")
self.assertEqual(self.app.apply_coupon("FREE100"), "Invalid coupon")
loader = unittest.TestLoader()
suite = unittest.TestSuite([loader.loadTestsFromTestCase(case) for case in (TestLogin, TestCart, TestCoupon)])
unittest.TextTestRunner(verbosity=2).run(suite)test_tc01_valid_login_ignores_case_and_spaces (__main__.TestLogin.test_tc01_valid_login_ignores_case_and_spaces) ... ok test_tc02_wrong_password_is_refused (__main__.TestLogin.test_tc02_wrong_password_is_refused) ... ok test_tc03_third_failure_locks_account (__main__.TestLogin.test_tc03_third_failure_locks_account) ... ok test_tc04_add_requires_login (__main__.TestCart.test_tc04_add_requires_login) ... ok test_tc05_quantity_boundaries (__main__.TestCart.test_tc05_quantity_boundaries) ... ok test_tc06_adding_again_increases_quantity (__main__.TestCart.test_tc06_adding_again_increases_quantity) ... ok test_tc07_remove_item (__main__.TestCart.test_tc07_remove_item) ... ok test_tc08_delivery_boundary (__main__.TestCart.test_tc08_delivery_boundary) ... ok test_tc09_coupon_needs_500 (__main__.TestCoupon.test_tc09_coupon_needs_500) ... ok test_tc10_coupon_gives_10_percent (__main__.TestCoupon.test_tc10_coupon_gives_10_percent) ... ok test_tc11_coupon_twice_does_not_stack (__main__.TestCoupon.test_tc11_coupon_twice_does_not_stack) ... FAIL test_tc12_unknown_coupon (__main__.TestCoupon.test_tc12_unknown_coupon) ... ok ====================================================================== FAIL: test_tc11_coupon_twice_does_not_stack (__main__.TestCoupon.test_tc11_coupon_twice_does_not_stack) ---------------------------------------------------------------------- Traceback (most recent call last): File "<exec>", line 137, in test_tc11_coupon_twice_does_not_stack AssertionError: 512.0 != 576.0 ---------------------------------------------------------------------- Ran 12 tests in 0.369s FAILED (failures=1)
Eleven of twelve tests pass. TC-11, the repetition test, fails: applying the coupon twice gives ₹512 (20% off) instead of ₹576. Notice that no test from the "normal" requirements caught this; it came from an error-guessing idea, "what if the user does it twice?", the same heuristic you met in Exploratory Testing.
Step 5: write the bug report
Before anyone fixes anything, report the defect clearly, as in Bug Life Cycle & Bug Reports:
ID: BUG-001
Title: FEST10 discount doubles when the coupon is applied twice to the same order
Environment: Kirana Kart 1.0, test build of 2 Nov 2026
Requirement: R7 (a coupon can be used only once per order)
Preconditions: Logged in as priya@example.com, empty cart
Steps to reproduce:
1. Add ghee-1l (Rs 640) to the cart
2. Apply coupon FEST10
3. Apply coupon FEST10 again
Expected result: Total stays Rs 576.00 (10% off once), or the second attempt is refused
Actual result: Total becomes Rs 512.00 (20% off)
Reproducibility: Always
Severity: Major (direct money loss on every repeated apply)
Priority: P1 (customers will find this quickly, for example by double-tapping)
Evidence: automated test test_tc11_coupon_twice_does_not_stack fails with 512.0 != 576.0Step 6: the fix and the retest
The developer finds the cause: the coupon percentage is added each time instead of set. The fix is one line:
- self.coupon_percent += percent # defect: applying twice stacks the discount
+ self.coupon_percent = percent # fixed: a second apply replaces, never stacksMake this change in the Step 4 example and run it again. First, confirmation testing: TC-11 now passes. Then, regression testing: the other eleven tests still pass, so the fix broke nothing. The run ends with Ran 12 tests and OK, and BUG-001 can move from Retest to Verified and Closed. Because the test is automated, this defect can never come back silently.
Step 7: UI tests for the web version
The business rules are now covered by fast unit tests. The web version of the shop needs only a few end-to-end checks of real user journeys, following the test pyramid. With Playwright and page objects (see Page Object Model), two journeys might look like this:
# test_ui_journeys.py (needs pytest-playwright and the web build running)
from playwright.sync_api import Page, expect
BASE = "http://localhost:8000"
def log_in(page: Page):
page.goto(f"{BASE}/login")
page.get_by_label("Email").fill("priya@example.com")
page.get_by_label("Password").fill("Kart@2026")
page.get_by_role("button", name="Log in").click()
def test_add_ghee_and_apply_coupon(page: Page):
log_in(page)
page.get_by_role("button", name="Add Ghee 1 L to cart").click()
page.get_by_role("link", name="Cart").click()
page.get_by_label("Coupon code").fill("FEST10")
page.get_by_role("button", name="Apply").click()
expect(page.get_by_test_id("order-total")).to_have_text("₹576.00")
def test_lockout_message_after_three_failures(page: Page):
for _ in range(3):
page.goto(f"{BASE}/login")
page.get_by_label("Email").fill("priya@example.com")
page.get_by_label("Password").fill("wrong-pass")
page.get_by_role("button", name="Log in").click()
expect(page.get_by_role("alert")).to_have_text("Account locked")The locators assume the web build uses proper labels, button names and a data-testid on the total, which is worth agreeing with the developers early. The second test uses its own browser page, so it cannot lock the account used by the first.
Step 8: CI and the summary report
Finally, the suite runs on every pull request with a workflow like the one in CI with GitHub Actions. Save the tests as test_kirana_kart.py next to the app and let discovery find them:
- name: Business rule tests
run: python -m unittest -v
- name: UI journeys
run: pytest test_ui_journeys.py --tracing retain-on-failureAt the end of the cycle, write a short test summary report for the product owner:
Kirana Kart 1.0 - test summary
Scope tested: R1-R7 (login, lockout, cart, delivery, coupon). Payment not in scope.
Executed: 12 automated test cases (12 passed after fix), 2 UI journeys planned.
Defects: BUG-001 (coupon stacking, Major/P1) found, fixed, verified, closed.
Open questions: coupon behaviour when items are removed below Rs 500 (raised with product owner).
Exit criteria: met for the business rules. Recommendation: ready for UAT once the open question is answered.What you practised
You worked through the full testing life cycle on one small product: static review of requirements, planning, test design with equivalence partitions, boundaries, state transitions and error guessing, automation with unittest, defect reporting, confirmation and regression testing, UI automation design and CI. Real projects are bigger, but the steps are exactly these.
Key points
- Start by reviewing requirements and writing down questions; that is testing too.
- A short test plan with scope, approach, risks and exit criteria keeps the work focused.
- Name the design technique for each test case and trace every case to a requirement.
- Automate business rules as fast unit tests; keep a few UI tests for key journeys.
- A clear bug report plus an automated test makes the fix quick to verify and stops the defect returning.
Exercise
Remember the open question from the requirements: what happens to the coupon when items are removed? Using the Step 3 code, log in, add ghee (₹640) and sugar (₹45), apply FEST10, then remove the ghee. Print the total. The product owner answers: "the coupon must stop applying when the subtotal falls below ₹500". Is this a defect? Write a test test_tc13_coupon_dropped_below_500 and suggest a fix.
Show solution
app = KiranaKart()
app.login("priya@example.com", "Kart@2026")
app.add("ghee-1l")
app.add("sugar-1kg")
print(app.apply_coupon("FEST10"), app.total())
app.remove("ghee-1l")
print("After removing ghee:", app.subtotal(), "->", app.total())Coupon applied 616.5 After removing ghee: 45 -> 80.5
Yes, it is a defect: the subtotal is ₹45, so the total should be ₹45 + ₹40 delivery = ₹85, but the 10% discount still applies and gives ₹80.50. A test and a possible fix:
def test_tc13_coupon_dropped_below_500(self):
self.app.add("ghee-1l")
self.app.add("sugar-1kg")
self.app.apply_coupon("FEST10")
self.app.remove("ghee-1l")
self.assertEqual(self.app.total(), 85)def total(self):
subtotal = self.subtotal()
if subtotal < 500:
self.coupon_percent = 0 # coupon no longer valid
discount = round(subtotal * self.coupon_percent / 100, 2)
delivery = 0 if subtotal >= 499 or subtotal == 0 else 40
return round(subtotal - discount + delivery, 2)Add the test to the TestCoupon class. With both fixes in place, all 13 tests pass. A question asked during the requirements review turned into a real defect, which is why static testing pays off.
Quick Quiz
1. Which test design technique found the coupon-stacking defect?
Repeating an action is a classic error-guessing and exploratory heuristic.
2. After the fix, which test run confirms that BUG-001 itself is fixed?
Confirmation (re)testing reruns the test that failed; regression testing reruns the others.
3. Why are the test methods named like test_tc11_...?
Matching IDs keep the automated suite traceable to the designed test cases.
4. Why does the project keep only a few UI tests?
Following the test pyramid keeps the suite fast while still checking real user journeys.
Learn Software Testing with a mentor
Free lessons get you started. Our Software Testing training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
