What a test actually is
A test is a small program that runs part of your code with a known input and checks that the result matches what you expect. If it matches, the test passes. If not, it fails and tells you so. That is all. Frameworks add conveniences such as discovering tests and printing tidy reports, but the idea does not depend on them.
For a small project, this means you can start with a file containing a few lines like “call this function with these values, and complain if the answer is not this”. If your language ships a basic test module, using it is reasonable, but a plain script that exits with an error when a check fails is already a working test suite.
Why bother on a small project
Small programs still change. You add a feature, tweak a threshold, tidy a function, and each edit risks breaking something that used to work. A regression is exactly that: a previously working behaviour that stops working after a change. Without tests, you discover regressions when the device misbehaves, sometimes weeks later, which makes the cause hard to trace.
With tests, you re-run them after each change and find out within seconds. That makes you more willing to improve the code, since you have a safety net. Tests also record how the code is meant to behave, which helps a future reader.
What to test first
You cannot test everything, and you do not need to. Good targets are:
- Logic with clear inputs and outputs. Converting a raw sensor value to a temperature, parsing a message, calculating a duration, deciding whether to switch something on.
- Edge cases. Zero, negative numbers, the largest and smallest allowed values, empty input, a missing field.
- Things that broke before. When you fix a bug, add a test that reproduces it. That fault then cannot quietly return.
- Decision rules. Behaviour built from states and thresholds is easy to check, especially when written as a state machine with a function that maps the current state and an event to the next state.
Less valuable early on are tests of trivial code, or tests that simply restate the implementation line by line.
Make code easy to test
The main design habit is to separate calculation from side effects. A function that reads a pin, does maths and writes to a display in one go is hard to test without the hardware. If you split it into reading, deciding and acting, the middle step becomes a plain function of inputs, which you can test on any computer.
For example, a function that takes a moisture reading and returns “water” or “wait” can be tested with a handful of numbers, including values on each side of the threshold. The code that touches the pump stays thin and is checked on the real device.
When your code needs the outside world, such as the time, a network call or a sensor, pass that in as an argument or put it behind a small function that a test can replace with a fake. A fake that returns fixed values makes the test repeatable, which is the point.
A simple way to start
- Pick one function whose behaviour you are sure about.
- Write down a few input and expected output pairs, including an edge case.
- Write a script that calls the function for each pair and reports any mismatch, with enough detail to see which one failed.
- Run it, and fix whichever is wrong, the code or your expectation.
- Add a test each time you fix a bug or add a rule.
Keep each test small and focused on one behaviour, and give it a name that describes what it checks, since the name is what you read when it fails.
Checking results sensibly
Be careful with numbers that come from measurement or arithmetic on decimals. Floating-point results often differ in tiny amounts, so compare them within a small tolerance instead of expecting exact equality. Choose the tolerance on purpose, based on how much error matters to your project.
For output that has a clear expected form, such as a formatted message, compare the whole text. For output that depends on time or random values, make those controllable in the test.
Tests are not everything
Automated tests check what you thought to check. They do not replace trying the device in the real world, where noise, loose wires and unexpected conditions show up. Hardware behaviour, timing and electrical issues generally need hands-on checks, which is where methodical debugging comes in. Treat tests as a way to remove the easy-to-prevent failures so that your real-world testing can focus on the hard ones.
Keeping tests useful
- Run them often, ideally before every commit. A one-line command makes this a habit.
- Fix or delete failing tests promptly. A suite that is always red gets ignored.
- Keep them fast. Slow tests stop being run.
- Do not let them depend on each other or on leftover files.
As a project grows, you might run the tests automatically through CI pipelines whenever code is pushed, but that is a later step, and a local script works fine until then. Tests are best seen as a small, steady investment: a few minutes each time, repaid whenever a change would otherwise have broken something unnoticed.