← Back to dashboard

Lesson 25: SQL Practice & The Complete Testing Map

Multi-column GROUP BY · JOINs revisited · Every testing type in one place

⏱ ~55 min 📚 SQL + Testing 🎯 Exam-focused
1

SQL Practice

Multi-column GROUP BY · JOINs · Reading complex queries

1. Grouping by Two Columns at Once ~10 min

SE-11-04 — Data Storage & SQL

You already know that GROUP BY category gives you one row per category. But what if you need counts for every combination of two columns — like "pending software", "pending hardware", "escalated software", and "escalated hardware" all in one result?

You just group by both columns. SQL creates a bucket for each unique pair.

The Issues table

This is the table from your pre-work. It's a job tracker for a tech support company.

JobIDIssueFixTimeCategoryStatus
0001Cannot connect to internet22SoftwarePending
0002Installation failure76HardwarePending
0004Forgot password97SoftwareResolved
0005Installation failure110HardwareEscalated
0009Blue screen error120SoftwarePending
0011Installation failure45SoftwareEscalated

One column vs two columns

Compare these two queries side by side:

-- One column: one row per Category
SELECT Category, COUNT(*) AS job_count
FROM Issues
GROUP BY Category;

Result: 2 rows — Software (4 jobs), Hardware (2 jobs). You lose all information about status.

-- Two columns: one row per Category+Status combination
SELECT Category, Status, COUNT(*) AS job_count
FROM Issues
GROUP BY Category, Status
ORDER BY job_count DESC;

Result: one row for every unique pair that exists in the data:

CategoryStatusjob_count
SoftwarePending2
SoftwareEscalated1
SoftwareResolved1
HardwarePending1
HardwareEscalated1

The question only wanted Pending and Escalated rows, so you add a WHERE to remove Resolved before grouping:

SELECT Category, Status, COUNT(*) AS job_count
FROM Issues
WHERE Status IN ('Pending', 'Escalated')
GROUP BY Category, Status
ORDER BY job_count DESC;

The mental model

GROUP BY col1, col2 — think of it as drawing a grid. Each row of the grid is a unique combination. SQL counts (or sums, or averages) the rows that fall into each cell of that grid.

🎯 Check your pre-work answer

Look at the query you wrote for Q21. Does it use GROUP BY Category, Status? Does it have a WHERE to exclude Resolved? Does it end with ORDER BY job_count DESC?

Model answer for Q21
SELECT Category, Status, COUNT(*) AS job_count
FROM Issues
WHERE Status IN ('Pending', 'Escalated')
GROUP BY Category, Status
ORDER BY job_count DESC;

This gives exactly four rows — pending software, pending hardware, escalated software, escalated hardware — in descending count order.

2. JOINs — Connecting Tables ~10 min

A JOIN is how you answer questions that span two tables. The idea: find rows in both tables that share the same key value, and stitch them together into one wider row.

We'll use a new schema — a school's assignment tracker.

Schema

students
student_id (PK)nameyear
1Blake12
2Jordan11
3Alex12
submissions
sub_id (PK)student_id (FK)subjectscore
1011Software Engineering87
1021Maths72
1032Software Engineering91
1043Maths65

Without a JOIN, you can only ask questions about one table. To ask "what score did Blake get in Software Engineering?" you need both tables.

-- Show student name alongside their subject and score
SELECT students.name, submissions.subject, submissions.score
FROM students
INNER JOIN submissions ON students.student_id = submissions.student_id;

SQL walks every row of students, finds matching rows in submissions where student_id is equal, and glues them together:

namesubjectscore
BlakeSoftware Engineering87
BlakeMaths72
JordanSoftware Engineering91
AlexMaths65

Notice Jordan has no row and Alex has one — that's fine, INNER JOIN only returns rows that have a match on both sides.

Syntax pattern: FROM table_a INNER JOIN table_b ON table_a.shared_key = table_b.shared_key. The ON clause is the glue — it tells SQL which columns must match.

🎯 JOIN + GROUP BY combined

This is the pattern exams love — it combines everything.

Write a query: for each student name, their average score across all submissions.
SELECT students.name, AVG(submissions.score) AS avg_score
FROM students
INNER JOIN submissions ON students.student_id = submissions.student_id
GROUP BY students.name;

Step by step: JOIN gives you name + score on each row → GROUP BY name buckets them per student → AVG averages each bucket.

Now: only students whose average score is 80 or above.
SELECT students.name, AVG(submissions.score) AS avg_score
FROM students
INNER JOIN submissions ON students.student_id = submissions.student_id
GROUP BY students.name
HAVING AVG(submissions.score) >= 80;

The condition uses an aggregate so it goes in HAVING, not WHERE.

Stretch: for each subject, the highest score — ordered highest first.
SELECT submissions.subject, MAX(submissions.score) AS top_score
FROM submissions
GROUP BY submissions.subject
ORDER BY top_score DESC;

No JOIN needed here — everything you need is in submissions. Only reach for a JOIN when you genuinely need columns from two different tables.

3. Read the Query — Predict the Output ~5 min

HSC exams often give you a query and ask you to explain what it returns in plain English, or to spot a mistake. Practice reading a query clause by clause from top to bottom.

SELECT students.name, COUNT(submissions.sub_id) AS total_submissions
FROM students
INNER JOIN submissions ON students.student_id = submissions.student_id
WHERE submissions.score >= 70
GROUP BY students.name
HAVING COUNT(submissions.sub_id) > 1
ORDER BY total_submissions DESC;

🎯 Translate into English

What does this query return?

"From the joined student/submission data, looking only at submissions where the score is at least 70 (WHERE), count how many such submissions each student has (GROUP BY + COUNT). Keep only students with more than one qualifying submission (HAVING). List them highest count first (ORDER BY)."

With our sample data: Blake has scores 87 and 72 — both ≥ 70, count = 2. Jordan has 91 — one submission ≥ 70. Alex has 65 — filtered out by WHERE. HAVING then drops Jordan (only 1). Result: just Blake (2).

What would change if you replaced HAVING COUNT > 1 with WHERE COUNT > 1?

It would be a syntax error — you can't use an aggregate function like COUNT inside a WHERE clause. WHERE runs before groups are formed, so COUNT doesn't exist yet at that point. HAVING is the right tool whenever your condition involves an aggregate.

2

The Complete Testing Map

Every testing concept in the HSC syllabus — unified in one place

🚀 Setup — do this before the first exercise

Get the coding exercises onto your laptop

Download the six exercise files below into a new folder called testing_exercises. Click each link — your browser will save the .py file.

Download the files

01_testing_levels.py
02_test_data_strategies.py
03_box_testing.py
04_debugging_toolkit.py
05_interface_debugging.py
06_sast_static_analysis.py

Set up your workspace

Put all six files in one folder, then open that folder in VSCode so the debugger is ready for exercises 4 and 5:

code testing_exercises

To run an exercise, open its file and press the ▶ Run button, or use the terminal:

python3 01_testing_levels.py

You're set. Each section below will tell you which file to open when it's time to code.

4. Testing Levels — Scope ~8 min

SE-11-07 — Testing & Evaluating
Level 1 — smallest

Unit Testing

Test a single function or method in complete isolation. Nothing else runs. You control every input and check every output.

Level 2 — medium

Subsystem Testing

Test a group of related units working together — e.g. the entire login flow (form → validation → database check → session). You're checking that the parts integrate correctly.

Level 3 — largest

System Testing

Test the entire application end-to-end, as a real user would. Does every feature work? Does the app behave correctly as a whole?

Concrete example — your pet simulator

LevelWhat you'd test
UnitDoes Pet.feed() increase hunger by exactly 10? Test it alone with a known starting state.
SubsystemDo feed(), play(), and sleep() interact correctly? Does feeding after sleep still work?
SystemRun the whole game from start to end. Can a user create a pet, keep it alive through a full session, and save their progress?

Order matters: you always run unit tests first. Finding a bug at the unit level is cheap — you know exactly which function is wrong. Finding it at the system level means digging through the entire app.

🎯 Classify the scope

A developer tests the calculate_discount() function with a dozen different price inputs.

Unit test — single function, tested in isolation.

A tester signs up for a new account, adds items to a cart, completes checkout, and checks the order confirmation email arrives.

System test — the whole application, end-to-end, as a real user.

A developer tests that the shopping cart correctly applies a coupon code AND updates the total price displayed on screen.

Subsystem test — two components (coupon engine + display) working together.

💻
Coding exercise

Write unit, subsystem, and system tests on a Pet class

Open testing_exercises/01_testing_levels.py. You'll write three test functions — one at each scope level — against the same Pet class.

5. Test Data Strategies — What You Feed In ~8 min

SE-11-07 — Testing & Evaluating

Choosing what data to test with is its own skill. Exams will give you a function and ask you to pick appropriate test data — or spot which strategy a given test case represents.

Strategy 1

Boundary Value Testing

Test at the exact edges of valid input — and one step either side. Systems most often fail right at boundaries.

Strategy 2

Path Coverage Testing

Design tests so that every possible branch in the code is executed at least once. An untested branch is a hidden bug waiting to happen.

Strategy 3

Faulty / Abnormal Data

Feed the system inputs it wasn't designed for — wrong types, empty strings, negative numbers, absurdly large values. Does it crash or handle it gracefully?

Boundary values in detail

Say a function accepts an age between 0 and 120 inclusive. The boundary test cases are:

InputWhyExpected
-1Just below lower boundaryReject / error
0Exactly at lower boundaryAccept
1Just inside lower boundaryAccept
60Normal mid-range valueAccept
119Just inside upper boundaryAccept
120Exactly at upper boundaryAccept
121Just above upper boundaryReject / error

Path coverage in detail

Look at this function:

def classify_score(score):
    if score >= 90:
        return "Outstanding"
    elif score >= 75:
        return "Merit"
    elif score >= 50:
        return "Pass"
    else:
        return "Fail"

There are 4 branches (paths). Full path coverage requires at least one test case that hits each branch:

Test inputBranch hit
95"Outstanding" branch
80"Merit" branch
60"Pass" branch
40"Fail" branch

Testing only score = 95 gives you 25% path coverage. If the "Fail" branch had a typo, you'd never catch it.

🎯 Strategy identification

A function accepts a password (6–20 characters). A tester uses: "ab", "abcdef", "abcde12345", "abcdefghijklmnopqrst", "abcdefghijklmnopqrstu"

Boundary value testing. "ab" = below minimum, "abcdef" = exactly at minimum, "abcdefghijklmnopqrst" = exactly at maximum (20 chars), "abcdefghijklmnopqrstu" = one above maximum. The mid-range value is also included for coverage.

A tester submits -999, "", None, and "abc" to a field that expects a positive integer.

Faulty / abnormal data. None of these are valid inputs. The goal is to confirm the system handles bad data without crashing.

A developer writes four test cases for a function with an if/elif/elif/else structure, choosing inputs so each branch is reached at least once.

Path coverage testing. The explicit goal is to ensure every branch is exercised.

💻
Coding exercise

Write boundary, path coverage, and faulty-data tests

Open testing_exercises/02_test_data_strategies.py. Three small functions, three strategies — fill in the test cases for each.

6. Testing Approaches — How Much Do You Know? ~5 min

The "box" metaphor refers to how much of the code's internals you can see while you're testing.

Approach 1

Black Box

You can only see the outside. You know what inputs to give and what output is expected, but you have no knowledge of the code inside. Tests are driven entirely by the specification.

Approach 2

White Box

Full visibility of the source code. You design tests specifically to exercise every line, branch, and path. Path coverage testing is a white box strategy.

Approach 3

Grey Box

Partial knowledge. You know some internals (e.g. the database schema or API structure) but not all. Common in integration and security testing.

Who uses which?

ApproachTypical userTypical scenario
Black boxQA tester, client"Does the login page accept valid credentials and reject invalid ones?"
White boxDeveloper (themselves)"I'll trace through my function and write a test for each branch."
Grey boxPen tester, integration tester"I know the API uses JWTs — let me test what happens with a forged token."

Common exam trap

Black/white/grey box describes how much you know, not what level you're testing at. You can do a black box unit test. You can do a white box system test. The two dimensions are independent.

💻
Coding exercise

Test the same function black box then white box

Open testing_exercises/03_box_testing.py. First test a "mystery function" from its spec only — then re-test it knowing the implementation. Feel the difference.

7. The Debugging Toolkit ~7 min

SE-11-07 — Debugging

You've been using print() to debug since day one. That's one tool in a full toolkit. Each tool is suited to a different kind of problem.

🖨️
Print Statements
Add print() to trace values. Fast to add, zero setup. Clutters code if forgotten.
🔴
Breakpoints
Pause execution at a chosen line. Program freezes — you inspect the full state at that moment.
👣
Single-line Stepping
Execute one line at a time. Watch variables update with each step. Ideal for pinpointing exactly where logic breaks.
👁️
Watches
Pin a variable to a watch panel. Its value updates automatically as you step through code — no print needed.
🧰
IDE Debugger
The full panel: call stack, variable inspector, watch list, step controls. VSCode's debugger has all of these.
🔗
Interface Checks
Trace the data passed between functions. A bug at a function boundary — wrong type, wrong shape — can look like an error inside the callee.

Breakpoints + stepping together

The workflow is: set a breakpoint → run the program → it pauses → use single-line stepping to walk forward one line at a time → watch the variables panel to see every change.

This is far more powerful than print statements for complex bugs, because you see the entire program state, not just the variables you thought to print.

Debugging interfaces between functions

This is a specific skill the syllabus calls out. When a function calls another function, the data it passes (the interface) can be wrong even if both functions individually work correctly. Classic symptoms:

  • A function returns a list but the caller expects a string
  • A function returns a number but the caller multiplies by a string
  • A function returns None (forgot a return statement) and the caller crashes

To debug an interface, set a breakpoint just after the function call and inspect what was returned — before the caller does anything with it.

🎯 Pick the right tool

Your loop is running but producing wrong values at some iteration — you don't know which one.

Breakpoint + single-line stepping (or a watch). Set a breakpoint inside the loop body and step through iterations, watching the variable change each time.

You suspect the function get_discount() is returning the wrong type — but apply_discount() that calls it is where the crash happens.

Interface debugging. Set a breakpoint in apply_discount() at the line that calls get_discount() and inspect what it returns before applying it. The bug is in the return value, not in apply_discount() itself.

You need a quick check that a variable has the right value at one specific point in your code.

Print statement — it's the simplest and fastest tool when you know exactly what you want to check and where.

💻
Coding exercise — same bug, three tools

Hunt one bug with print, breakpoints, and a watch

Open testing_exercises/04_debugging_toolkit.py. There's a real bug in calculate_average_grade(). Find it three different ways — printing, breakpoints + stepping, and a watch expression. You'll need VSCode for the last two.

🔗
Coding exercise — interface debugging

Find the bug that lives between two functions

Open testing_exercises/05_interface_debugging.py. Both functions look correct individually. The bug only shows when one calls the other. Use a breakpoint to inspect the value at the boundary.

8. Security Testing — A Separate Layer ~5 min

SE-12-03 — Secure Software

Security testing asks a different question: not "does the software work correctly?" but "can it be broken by someone who's trying?" These tools live in the Secure Software module but they're still testing — they belong on the same map.

No execution needed

SAST — Static Analysis

Scans source code without running it. Finds issues like hardcoded secrets, SQL injection patterns, or unsafe function calls. Runs in CI pipelines — catches bugs before they ship.

Live app required

DAST — Dynamic Analysis

Runs the application and probes it from the outside — sending malformed requests, fuzzing inputs, checking responses. Finds vulnerabilities that only appear at runtime.

Systematic scan

Vulnerability Assessment

A structured survey of known weaknesses — checks patch levels, configurations, open ports, dependency versions. Produces a risk-ranked list. Doesn't actively exploit anything.

Active attack simulation

Penetration Testing

A tester (with permission) actively tries to break in — exploiting vulnerabilities, chaining attacks, escalating privileges. Proves what an attacker could actually achieve.

Stress under attack

Resilience Testing

Tests how the system behaves under attack or unexpected load — DoS attempts, database floods, service disruptions. Does it degrade gracefully or collapse entirely?

Key distinction to know for exams

ToolNeeds running app?Actively attacks?
SASTNo — reads source codeNo
DASTYes — probes live appNo (automated)
Vulnerability assessmentYes (usually)No — only identifies
Penetration testingYesYes — exploits findings
Resilience testingYesYes — stresses the system
💻
Coding exercise — SAST by hand

Spot vulnerabilities by reading code (don't run it)

Open testing_exercises/06_sast_static_analysis.py. Five code snippets, each with a vulnerability. Identify it, name the category, and write the fix — without ever executing the file. This is exactly what a SAST tool does for you automatically.

9. The Big Classification Challenge ~7 min

HSC exams love asking you to identify the type of testing from a scenario. Read each one and name the correct term — some scenarios might have more than one correct answer.

Scenario A: A developer sets a breakpoint in her login function and steps through line by line, watching the user_id variable update after each database call.

Breakpoints + single-line stepping + watches. She's using the full IDE debugging toolkit on a single function — also white box (she's inside the code).

Scenario B: A QA tester for a flight booking site checks that searching for "Sydney to London" returns correct flights, completes a booking, and receives a confirmation email — all without looking at any source code.

System testing + black box. End-to-end (full system), with no knowledge of internals (black box).

Scenario C: A team scans their web app's source code repository for any lines that construct SQL queries using string concatenation.

SAST (Static Application Security Testing). Analysing source code without running the application.

Scenario D: For a function that accepts a percentage (0–100), a tester uses: -1, 0, 1, 50, 99, 100, 101.

Boundary value testing. Inputs at, just below, and just above both the lower (0) and upper (100) boundaries.

Scenario E: A security professional (with written permission from the client) attempts to log in as an admin by modifying JWT tokens in the browser, chaining this with an insecure API endpoint to extract a list of all user emails.

Penetration testing. Active, authorised attack simulation — exploiting real vulnerabilities to prove what an attacker could achieve.

Scenario F: A developer tests that calculate_tax(), apply_discount(), and get_total() produce the correct final price when called in sequence — but does not test the entire checkout process.

Subsystem testing. Three related functions working together — larger than unit, smaller than system.

Scenario G: A developer writes four test cases for a function with four if/elif/else branches, choosing inputs that guarantee every branch executes at least once.

Path coverage testing + white box. Explicit goal is full branch coverage (path coverage); requires knowledge of the code structure (white box).

Scenario H: A team simulates a flood of 50,000 simultaneous requests to their login endpoint to observe whether the server crashes, throttles, or handles the load gracefully.

Resilience testing. Testing system behaviour under extreme/attack-like stress to see if it degrades gracefully or fails completely.

10. Wrap-Up ~2 min

SQL — what's new today

  • GROUP BY two columns — creates one row per unique combination of both values.
  • WHERE before, HAVING after — filter rows before grouping, filter groups after aggregating.
  • INNER JOIN … ON — link rows from two tables on a shared key; only returns rows with a match on both sides.

Testing — the complete map

QuestionTerms
What scope?Unit → Subsystem → System
What data?Boundary · Path coverage · Faulty/abnormal
What knowledge?Black box · White box · Grey box
What debugging tool?Print · Breakpoint · Stepping · Watch · IDE · Interface check
Security-specific?SAST · DAST · Vulnerability assessment · Pen testing · Resilience

The exam approach

When a question describes a testing scenario, run through the three questions: what scope? what data strategy? what knowledge level? Then check if it's security-specific. One scenario can correctly answer multiple questions — that's fine.