Search
Unit Testing for Beginners: Write Your First Tests in Python and JavaScript (unittest, pytest, node:test)
Here is a situation every student developer knows. Your POS system worked yesterday. Today you changed one line in the discount code, and now the change computation is wrong, but you only find out during the capstone defense when a panelist buys a ₱99.95 item with a ₱100 bill. Unit tests exist to catch that kind of mistake in seconds, long before anyone else sees it.
Many beginners skip testing because it sounds like extra work for big companies. It is not. A unit test is just a small function that calls your code and checks the answer. In this guide you will write your first tests in Python (with the built-in unittest module and with pytest) and in JavaScript (with Node.js's built-in node:test). You will also see a real bug caught by a test. Every example was run on Python 3.13 and Node.js 24, and the outputs are copied from the terminal.
What Is a Unit Test?
A unit is a small piece of code you can check on its own, usually one function. A unit test gives that function some input and compares the result with what you expect. Most tests follow the same three steps, often called Arrange, Act, Assert:
- Arrange: prepare the input (a cart, an amount, a student's grades).
- Act: call the function.
- Assert: check that the result is what it should be.
Because tests are code, you can run all of them with one command any time you change something. That is the real benefit: you stop being afraid to change your own project.
The Code We Will Test
Two small functions you might find in a student project: computing change in a POS, and turning an average into Passed or Failed. Save this as pos.py:
def compute_change(amount_due, cash):
"""Return the change for a sale. Raises ValueError if cash is not enough."""
if amount_due < 0 or cash < 0:
raise ValueError("amounts cannot be negative")
if cash < amount_due:
raise ValueError("insufficient cash")
return cash - amount_due
def grade_remarks(average):
"""Return 'Passed' for 75 and above, otherwise 'Failed'."""
if not 0 <= average <= 100:
raise ValueError("average must be between 0 and 100")
return "Passed" if average >= 75 else "Failed"
Your First Tests with Python unittest
unittest comes with Python, so there is nothing to install. Tests are methods whose names start with test_, inside a class that extends unittest.TestCase. Save this as test_pos_unittest.py in the same folder:
import unittest
from pos import compute_change, grade_remarks
class ComputeChangeTests(unittest.TestCase):
def test_exact_cash_gives_zero_change(self):
self.assertEqual(compute_change(150, 150), 0)
def test_change_for_bigger_bill(self):
# Arrange
amount_due, cash = 385, 500
# Act
change = compute_change(amount_due, cash)
# Assert
self.assertEqual(change, 115)
def test_insufficient_cash_raises_error(self):
with self.assertRaises(ValueError):
compute_change(500, 200)
class GradeRemarksTests(unittest.TestCase):
def test_75_is_passing(self):
self.assertEqual(grade_remarks(75), "Passed")
def test_74_is_failing(self):
self.assertEqual(grade_remarks(74), "Failed")
def test_out_of_range_average_raises_error(self):
with self.assertRaises(ValueError):
grade_remarks(101)
if __name__ == "__main__":
unittest.main()
Run it from the project folder (on Windows, use python instead of python3):
$ python3 -m unittest -v test_pos_unittest test_change_for_bigger_bill (test_pos_unittest.ComputeChangeTests.test_change_for_bigger_bill) ... ok test_exact_cash_gives_zero_change (test_pos_unittest.ComputeChangeTests.test_exact_cash_gives_zero_change) ... ok test_insufficient_cash_raises_error (test_pos_unittest.ComputeChangeTests.test_insufficient_cash_raises_error) ... ok test_74_is_failing (test_pos_unittest.GradeRemarksTests.test_74_is_failing) ... ok test_75_is_passing (test_pos_unittest.GradeRemarksTests.test_75_is_passing) ... ok test_out_of_range_average_raises_error (test_pos_unittest.GradeRemarksTests.test_out_of_range_average_raises_error) ... ok ---------------------------------------------------------------------- Ran 6 tests in 0.000s OK
Six tests, all passing. Notice how the test names read like sentences: test_insufficient_cash_raises_error tells you exactly what broke if it ever fails. Also notice the two grade tests: 75 and 74. Testing right at the boundary is where you catch the classic > versus >= mistake.
Shorter Tests with pytest
pytest is a very popular testing tool for Python. Install it with pip install pytest. With pytest, a test is a plain function and you check results with a normal assert. Its parametrize feature lets you run the same test with many inputs. Save this as test_pos.py:
import pytest
from pos import compute_change, grade_remarks
@pytest.mark.parametrize("amount_due, cash, expected", [
(150, 150, 0),
(385, 500, 115),
(99.95, 100, 0.05), # pesos and centavos
(1234.50, 1300, 65.50),
])
def test_compute_change(amount_due, cash, expected):
assert compute_change(amount_due, cash) == expected
def test_insufficient_cash_raises_error():
with pytest.raises(ValueError, match="insufficient cash"):
compute_change(500, 200)
@pytest.mark.parametrize("average, expected", [
(100, "Passed"),
(75, "Passed"),
(74.99, "Failed"),
(0, "Failed"),
])
def test_grade_remarks(average, expected):
assert grade_remarks(average) == expected
def test_grade_remarks_rejects_out_of_range():
with pytest.raises(ValueError):
grade_remarks(-1)
I added a case with centavos: ₱99.95 due, ₱100 cash, so the change should be ₱0.05. Here is what happened when I ran pytest:
$ pytest
============================= test session starts ==============================
platform linux -- Python 3.13.5, pytest-9.2.0, pluggy-1.7.0
rootdir: /tmp/pos-tests
collected 16 items
test_pos.py ..F....... [ 62%]
test_pos_unittest.py ...... [100%]
=================================== FAILURES ===================================
_____________________ test_compute_change[99.95-100-0.05] ______________________
amount_due = 99.95, cash = 100, expected = 0.05
@pytest.mark.parametrize("amount_due, cash, expected", [
(150, 150, 0),
(385, 500, 115),
(99.95, 100, 0.05), # pesos and centavos
(1234.50, 1300, 65.50),
])
def test_compute_change(amount_due, cash, expected):
> assert compute_change(amount_due, cash) == expected
E assert 0.04999999999999716 == 0.05
E + where 0.04999999999999716 = compute_change(99.95, 100)
test_pos.py:12: AssertionError
=========================== short test summary info ============================
FAILED test_pos.py::test_compute_change[99.95-100-0.05] - assert 0.0499999999...
========================= 1 failed, 15 passed in 0.03s =========================
The Test Found a Real Bug
Read the failure carefully. pytest shows the inputs (amount_due = 99.95, cash = 100), the line that failed, and the actual result: 0.04999999999999716 instead of 0.05. This is not a pytest problem. Computers store decimal numbers like 99.95 in binary floating point, which cannot represent most decimal fractions exactly, so 100 - 99.95 comes out a tiny bit off. On a receipt that would show up as ₱0.04 or as a confusing long number, and a comparison like change == 0.05 in your code would be false.
Without the test, this bug would have waited for a real customer. With the test, the fix took one line. Update the return in compute_change():
return round(cash - amount_due, 2) # money: keep 2 decimal places
Run the tests again, this time with -v to see every test by name. pytest also runs the unittest tests from the previous section automatically:
$ pytest -v ============================= test session starts ============================== platform linux -- Python 3.13.5, pytest-9.2.0, pluggy-1.7.0 -- /tmp/venv-pt/bin/python3 rootdir: /tmp/pos-tests collecting ... collected 16 items test_pos.py::test_compute_change[150-150-0] PASSED [ 6%] test_pos.py::test_compute_change[385-500-115] PASSED [ 12%] test_pos.py::test_compute_change[99.95-100-0.05] PASSED [ 18%] test_pos.py::test_compute_change[1234.5-1300-65.5] PASSED [ 25%] test_pos.py::test_insufficient_cash_raises_error PASSED [ 31%] test_pos.py::test_grade_remarks[100-Passed] PASSED [ 37%] test_pos.py::test_grade_remarks[75-Passed] PASSED [ 43%] test_pos.py::test_grade_remarks[74.99-Failed] PASSED [ 50%] test_pos.py::test_grade_remarks[0-Failed] PASSED [ 56%] test_pos.py::test_grade_remarks_rejects_out_of_range PASSED [ 62%] test_pos_unittest.py::ComputeChangeTests::test_change_for_bigger_bill PASSED [ 68%] test_pos_unittest.py::ComputeChangeTests::test_exact_cash_gives_zero_change PASSED [ 75%] test_pos_unittest.py::ComputeChangeTests::test_insufficient_cash_raises_error PASSED [ 81%] test_pos_unittest.py::GradeRemarksTests::test_74_is_failing PASSED [ 87%] test_pos_unittest.py::GradeRemarksTests::test_75_is_passing PASSED [ 93%] test_pos_unittest.py::GradeRemarksTests::test_out_of_range_average_raises_error PASSED [100%] ============================== 16 passed in 0.02s ==============================
All 16 pass. Rounding is fine for a school project; for real accounting software, store money as whole centavos (integers) or use Python's decimal module, which is exactly what the JavaScript example below does.
Testing JavaScript with node:test
Node.js has a built-in test runner, node:test, so you do not need Jest or Mocha for simple projects. Here is a shopping cart total that keeps prices in centavos to avoid the floating-point problem we just saw. Save it as cart.js:
// Prices are in centavos (whole numbers) to avoid floating-point surprises.
function cartTotal(items, discountPercent = 0) {
if (discountPercent < 0 || discountPercent > 100) {
throw new RangeError('discount must be 0 to 100');
}
const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
const discount = Math.round(subtotal * discountPercent / 100);
return subtotal - discount;
}
function formatPeso(centavos) {
return '₱' + (centavos / 100).toLocaleString('en-PH', { minimumFractionDigits: 2 });
}
module.exports = { cartTotal, formatPeso };
Then save the tests as cart.test.js. Node finds files ending in .test.js automatically:
const test = require('node:test');
const assert = require('node:assert/strict');
const { cartTotal, formatPeso } = require('./cart.js');
test('empty cart costs nothing', () => {
assert.equal(cartTotal([]), 0);
});
test('adds price x quantity for every item', () => {
const items = [
{ name: 'Notebook', price: 4500, qty: 3 }, // ₱45.00 each
{ name: 'Ballpen', price: 1250, qty: 2 }, // ₱12.50 each
];
assert.equal(cartTotal(items), 16000); // ₱160.00
});
test('applies a 20% discount', () => {
const items = [{ name: 'Calculator', price: 89900, qty: 1 }];
assert.equal(cartTotal(items, 20), 71920);
});
test('rejects a discount above 100%', () => {
assert.throws(() => cartTotal([], 150), RangeError);
});
test('formats centavos as pesos', () => {
assert.equal(formatPeso(123456), '₱1,234.56');
assert.equal(formatPeso(5000), '₱50.00');
});
Run node --test in the folder:
$ node --test ✔ empty cart costs nothing (2.806299ms) ✔ adds price x quantity for every item (0.173744ms) ✔ applies a 20% discount (0.100998ms) ✔ rejects a discount above 100% (0.357458ms) ✔ formats centavos as pesos (88.398285ms) ℹ tests 5 ℹ suites 0 ℹ pass 5 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 189.559576
The structure is the same as in Python: a name that describes the behavior, a call, and an assertion. node:assert/strict gives you assert.equal, assert.deepEqual for arrays and objects, and assert.throws for errors.
What Should You Test?
You do not need to test everything. Start with the code where a mistake would hurt the most:
- Calculations: totals, discounts, VAT, change, grades, payroll deductions, penalties, interest.
- Rules and decisions: passed or failed, late or on time, allowed or not allowed, low stock or not.
- Edge cases: zero, empty lists, the exact boundary (75, not just 80 and 60), negative numbers, very large values.
- Error handling: what should happen when the cash is not enough or the input is invalid.
- Every bug you fix: write a test that fails because of the bug, fix the code, and keep the test. That bug can never quietly return.
Tips for Writing Good Tests
- One behavior per test. When a test named
test_74_is_failingfails, you know exactly where to look. - Keep business logic in plain functions. Code mixed with
input(),print(), HTML and SQL is hard to test. Move the calculation into a function that takes values and returns a value, likecompute_change()above. - Tests must not depend on each other or on the order they run in.
- Run tests before every commit. It takes a second. Later you can run them automatically on every push with GitHub Actions.
- Do not chase 100% coverage. Ten good tests on the important logic are worth more than a hundred tests that only check getters.
- Review AI-written tests. If an assistant writes your tests, check that the expected values are actually correct. A test that expects the wrong answer just locks the bug in.
How Tests Help in Your Capstone Defense
Panelists often ask, "How do you know your computation is correct?" A folder of passing tests is a much stronger answer than "we tried it manually." You can show the test cases for your grading formula, your billing computation or your inventory rules, and run them live in a few seconds. It also shows that your team understands software quality, not just features.
Conclusion
Unit testing is not a big-company ritual. It is a small habit: write a function, write a few tests for it, and run them every time you change something. Start today with the most important calculation in your current project. Use unittest or pytest in Python and node:test in JavaScript, test the boundaries, and add a test for every bug you fix. The first time a test catches a mistake like the ₱0.05 change above, you will never want to code without them again.