EthicsComputerCourses
Join training +91 90532 10052

Project: Test a Login & Cart App

Software Testing · Lesson 30 of 30 Project 9 min read Updated

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:

YAML
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:

ReqIDTest caseTechniqueExpected result
R1TC-01Log in with " Priya@Example.com" and the right passwordEquivalence (valid), data variation"Welcome"
R2TC-02Log in with the password in a different caseNegative"Invalid email or password"
R3TC-03Three wrong passwords, then the right oneState transition"Account locked" both times
R4TC-04Add an item without logging inNegativeRefused with an error
R4TC-05Add quantity 0, 1, 10 and 11Boundary values1 and 10 accepted; 0 and 11 refused
R4TC-06Add the same item twice (1 then 2)EquivalenceQuantity 3
R5TC-07Add then remove the only itemEquivalenceTotal ₹0
R6TC-08Subtotal ₹498 and ₹499Boundary valuesTotals ₹538 and ₹499
R7TC-09Coupon on a ₹499 subtotalBoundary valuesRefused with the ₹500 message
R7TC-10Coupon "fest10" on ₹640 gheeEquivalence, case variationTotal ₹576.00
R7TC-11Apply FEST10 twice on ₹640Error guessing (repetition)Total still ₹576.00
R7TC-12Unknown code FREE100Negative"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.

PythonRun it yourself
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())
Output
Welcome
Cart: {'ghee-1l': 1, 'tea-500g': 2} | subtotal: 1180
Coupon applied | total: 1062.0

The 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.

PythonRun it yourself
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)
Output
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:

Text
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.0

Step 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:

Diff
-        self.coupon_percent += percent     # defect: applying twice stacks the discount
+        self.coupon_percent = percent      # fixed: a second apply replaces, never stacks

Make 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:

Python
# 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:

YAML
- name: Business rule tests
  run: python -m unittest -v
- name: UI journeys
  run: pytest test_ui_journeys.py --tracing retain-on-failure

At the end of the cycle, write a short test summary report for the product owner:

Text
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
Python
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())
Output
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:

Python
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)
Python
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?

2. After the fix, which test run confirms that BUG-001 itself is fixed?

3. Why are the test methods named like test_tc11_...?

4. Why does the project keep only a few UI tests?

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