Verdict
Yes
Test a helper function that holds its own logic with direct unit tests: call it with the main input and the edge inputs most likely to break, and assert on the return value.
Why
- Blast radiususers
- Change frequencyrarely
- Detectabilityeventually
- Reversibilitywith-effort
- Test costtrivial
Test a helper function that holds its own logic with direct unit tests. In the typical case, a pure function that parses or formats a value for several callers, Blast radius is users and Change frequency is rarely, because a wrong result reaches customers through every caller and shared helpers change a few times a year. Detectability is eventually and Reversibility is with-effort, because caller tests pass only the inputs each caller needs, and callers store the plausible wrong value from an edge input until a script repairs it. Test cost is trivial, because a test imports the helper and calls it with no mock. Rule R11 gives Test.
| When | Decision | Why |
|---|---|---|
| The helper has one caller, and the tests of that caller reach every branch of the helper with data that gives each branch a different result | Do not test the helper directly; test the function that calls it | Detectability moves to immediately and Reversibility to trivial, because the caller's tests fail when the helper breaks |
| The helper rounds amounts or converts currencies for invoices | Test mandatory, with rounding cases and boundary values | Blast radius rises to money and Reversibility to costly |
| The helper checks whether a user belongs to the organisation that owns a record | Test mandatory: one allowed case and one denied case | Blast radius rises to safety-or-legal and Detectability to never |
| The helper reads the system clock or a file | Test minimally: one test of the main path with a fixed clock or a temporary file | Test cost rises to moderate, because each test must first control the clock or the file |
| The helper lives in test code and builds fixtures for other tests | Do not test the fixture builder; the tests that use it fail when it breaks | Blast radius falls to internal, Detectability to immediately and Reversibility to trivial |
| The helper is a custom assertion in test code, such as `assertSameInstant` | Test: one test that shows the assertion fails for two different values | Blast radius falls to internal, but Detectability moves to never, because a broken assertion lets every test that uses it pass |
What breaks if you don't test
A bug in a shared helper reaches every caller at once, while caller tests stay green on the inputs that worked before. Suppose a change makes parse_duration("1h30m") return 60 minutes instead of 90, and the caller tests use whole hours only. Meeting rooms get booked for one hour, and support finds the pattern days after the release.
What you lose if you over-test
A direct test of a helper with one caller repeats the caller's tests. Merge the helper into its caller, and the direct test fails while users get the same result. Mocking a pure helper in caller tests hides its bugs instead: the mock keeps returning the old answer.
How to test
Import the helper and call it in unit tests, with no test doubles. The minimum is one test of the main path plus one per input likely to break: empty input, characters outside ASCII, and input the helper must reject. Keep inputs and expected outputs in one table with pytest parametrize or a parameterized test from the JUnit user guide. Keep one test of the main function too, because helper tests do not prove that the main function passes the right arguments.
Procedure and references
When the answer changes
- The helper has one caller whose tests reach all of its branches with data that tells the results apart.
- The helper calculates an amount that customers pay, or decides who may read a record.
- The helper is an assertion that other tests rely on to fail.
Real incident + Code example
The slug helper that dropped an alphabet
On a publishing platform I worked on, a slugify() helper turned article titles into URLs for the editor, the sitemap and the import job. A developer made it strip accents with Python's unicodedata.normalize("NFKD", ...) and an ASCII encode that ignored errors, so "Café" became "cafe". Cyrillic letters have no ASCII form, so every Ukrainian title became an empty string, which the helper's fallback turned into untitled. The caller tests used English titles and stayed green. For three weeks new Ukrainian articles shared one URL, until an editor found that her link opened someone else's story. A script rebuilt the slugs, and we added this test:
import pytest
from blog.text import slugify
@pytest.mark.parametrize("title, expected", [
("Hello World", "hello-world"),
(" Spaces around ", "spaces-around"),
("Café au lait", "cafe-au-lait"),
("Привіт світ", "привіт-світ"),
("!!!", "untitled"),
])
def test_slugify(title, expected):
assert slugify(title) == expected
The Ukrainian row fails against the ASCII version.
Related questions
FAQ
- Should we test helper functions?
Yes, test a helper function that holds its own logic by calling it directly and asserting on its return value. A helper that only forwards one value to another function needs no test of its own when the tests of its callers fail as soon as it breaks.
- Do I need to test helper methods?
Test a public helper method that parses or calculates, because the tests of its callers seldom try its edge inputs. Test a private helper method through the public method that calls it.
- Should I test helper functions or only the main function?
Test both when the main function's tests do not reach the helper's edge inputs: helper tests cover the inputs, and one main function test covers the wiring. Test only the main function when the helper has no other caller and the main function's tests reach every branch of the helper with data that gives each branch a different result.
- Should I write tests for my test helpers?
Write a test for a custom assertion helper, because a broken assertion lets every test that uses it pass. Skip tests for fixture builders, since the tests that use them fail when they break.