Testing 🧪
Testing is like the foundation of a house: nobody sees it, but without it everything collapses. It is not just a way to verify that code works, it’s also living documentation, a safety net for refactoring and, above all, the best antidote to weekend emergency calls. In this guide we’ll look at the options for building an effective testing strategy, from the foundations (unit tests) up to the roof (end-to-end), passing through all the tests that make the difference between “seems to work” and “actually works”.
Test pyramid: the pyramid of truth (or almost) 🏛️
If you thought pyramids were only for Egyptians, know that in software too the “Test Pyramid” is a sacred monument. At the bottom, lots of fast and reliable tests (unit); at the top, few slow and expensive tests (end-to-end). The higher you go, the slower, more fragile, and more expensive tests become to maintain. And if you think unit tests alone are enough, get ready to discover bugs that hide better than a developer on Friday afternoon.
- Unit: many, fast, ruthless
- Integration: fewer, but essential
- Functional/E2E: few, but without them you risk finding out the “Pay” button doesn’t pay
Types of tests 🧩
Unit tests: the code’s daily workout 💪
Unit tests are the code’s gym: small, fast, isolated. But watch out: if you limit yourself to testing only “happy path” cases, you risk having muscles only on your arms (and bugs everywhere else). Remember: mocks and stubs are your friends, but don’t overdo it or you risk testing only your illusions. Unit tests should be numerous, cover both normal cases and edge cases, and above all be independent from each other. If one fails, the whole thing shouldn’t collapse like a house of cards.
Integration tests: when code makes friends 🤝
Here we test the interactions between modules. There are various approaches:
- Big-bang: test everything together (spoiler: it often explodes).
- Top-down: start from the top and go down (great for onion architectures).
- Bottom-up: from the bottom up (easier at the start, but you risk getting lost along the way).
- Sandwich/hybrid: a bit of everything, like pizza with pineapple (not for everyone’s taste).
Integration tests are slower and more complex, but essential to avoid your microservices ignoring each other like remote-working colleagues. Hard to maintain, but if well designed they save you from bugs that only the interaction between components can generate.
Functional tests: simulated reality 🎭
Here we verify that the software really does what it promises. Functional requirements are tested, often through APIs or user interfaces. Don’t confuse them with performance or security tests: here we only look at whether it “does what it should”, not whether it does it in 2ms or without getting hacked by a bored script kiddie. They are often end-to-end and simulate the real user, so they are slow and fragile, but essential for sleeping soundly.
End-to-end (E2E) tests: the complete journey 🚦
E2E tests are the full-scale trial: they simulate the entire user journey, from login to checkout, going through every critical step and integrating every component of the system. The goal is to verify that all the parts — frontend, backend, database, external services — collaborate as expected. These tests are essential for discovering problems that only emerge when everything runs together, such as integration errors, session issues, or broken user flows.
When to use them?
- To validate the main flows (purchase, registration, password reset)
- To test the integration between frontend, backend, and database
- To simulate real scenarios and ensure the end user doesn’t get any surprises
Watch out: too many E2E tests can slow down the pipeline and complicate maintenance. Carefully choose the flows that are truly critical to cover.
Regression tests: the guardian of the past 🕰️
Regression tests ensure that new changes don’t break what already worked. Every time you fix a bug or add a feature, add a regression test too: your future self (and the team) will thank you. These tests are the project’s historical memory: they help prevent old problems from reappearing after a change, even months later.
When are they needed?
- After every bug fix
- After major refactoring
- When a feature is particularly delicate or prone to regressions
A good regression test suite is the best insurance against “zombie bugs” that come back when you least expect them.
Performance tests: trial by fire 🔥
Performance tests measure how well the system holds up under stress: response times, throughput, scalability. Better to discover the limits in the lab than during Black Friday. These tests help identify bottlenecks, memory leaks, and scalability issues before they become emergencies.
What to measure?
- Response time of APIs and web pages
- Ability to handle high loads and traffic spikes
- Horizontal and vertical scalability
- Stability over time under prolonged load
Automate performance tests and schedule them regularly: performance changes even with small code or infrastructure changes.
Security tests: the software’s vault 🔒
Security tests look for vulnerabilities, injections, XSS, and other threats. They’re not just for banks: even your blog deserves not to be hacked by the first passing script kiddie. These tests are essential for protecting sensitive data, ensuring user privacy, and complying with regulations like GDPR.
What to test?
- Input validation and sanitization
- Authorization and authentication
- Protection from SQL injection, XSS, CSRF, and other known vulnerabilities
- Security of APIs and inter-service communications
Tools like gosec can help you find vulnerabilities in Go code. Don’t neglect security tests: a small flaw can have big consequences.
Usability tests: the judgment of humans 👀
These tests evaluate user experience and accessibility. If the “Buy” button is invisible or the font is hard to read, better to know it before release than after. Usability tests help you understand whether the interface is intuitive, whether the flows are clear, and whether the product is accessible to people with disabilities too.
When to do them?
- Before releasing new UI or features
- After negative feedback from users
- When you want to improve user satisfaction and retention
This is where the human eye is needed: ask a colleague, a real user, or anyone who didn’t write the code. Usability can’t be improvised: testing with real people makes the difference. However, there are also automated tools to evaluate accessibility and usability, such as axe, Lighthouse, or WAVE: use them for a first analysis, but they don’t replace human judgment.
Acceptance tests: the customer is always right ✅
Acceptance tests verify that the software meets the customer’s or stakeholder’s requirements. If they pass, you can celebrate (but not too much: requirements change often). These tests are often written in collaboration with the customer, represent the “definition of done” for what “works” means, and, where possible, are automated to ensure consistency over time.
How are they written?
- Together with the customer or product owner, to clarify expectations and acceptance criteria
- In natural language or with tools like Gherkin, to be understandable to everyone
- Automated where possible, to ensure consistency over time
Acceptance tests are the foundation for avoiding misunderstandings and endless arguments about what’s “done”.
Compatibility tests: the world is varied 🌍
These tests check that the app works on different devices, browsers, and operating systems. Because not everyone uses your favorite browser (and someone still uses Internet Explorer… unbelievable but true). Compatibility tests are essential for consumer products, SaaS, and mobile apps.
When are they needed?
- Before every major release
- If you have users on different platforms or in international markets
- When you introduce new dependencies or frontend technologies
Automate with cross-browser testing tools or ask colleagues with different devices. Compatibility is often underrated… until the user reports start coming in.
Configuration tests: putting settings to the test ⚙️
Configuration tests verify that the application behaves correctly with different configurations: environment variables, feature flags, settings files, runtime parameters. They’re essential to prevent a simple configuration error from throwing everything into chaos, especially in cloud, container, or microservice environments.
When are they needed?
- When the app runs in different environments (dev, staging, prod)
- If you use feature flags or dynamic parameters
- After changes to configuration files or environment variables
Automate these tests to avoid surprises after deployment. A wrong configuration can cause more damage than a bug in the code!
Chaos testing: resilience under stress 🧨
Chaos testing (or chaos engineering) consists of simulating failures, outages, or adverse conditions (e.g. service crashes, network loss, high latency) to verify the system’s resilience. The goal is to discover weak points and improve robustness before problems show up in production.
When to use it?
- In distributed, cloud, or microservice architectures
- To validate failover, autoscaling, and recovery strategies
- Periodically, as part of the DevOps culture
Tools like Chaos Mesh, Gremlin, or simple custom scripts can help you introduce “controlled chaos”. Better to discover the limits in the lab than during a real incident!
Exploratory tests: the art of improvisation 🕵️
Here the tester follows their instinct, exploring the software without a precise script. Perfect for discovering bugs that no automated test will ever find. Exploratory tests are especially useful during prototyping or when working on new, poorly documented features.
Practical tips:
- Vary input data creatively and unpredictably
- Try undocumented flows or edge cases
- Note every suspicious behavior and share findings with the team
- Use time-boxed sessions to maximize effectiveness
Exploratory testing is a valuable resource for uncovering “hidden” problems and improving the product’s overall quality.
Contract tests: clear agreements between services 📜
Contract tests verify that APIs respect the contracts established between services. This way you avoid microservice A speaking Klingon while B speaks Elvish. They are essential in microservice architectures and for integration with external systems.
When to use them?
- When multiple teams work on different services that need to communicate with each other
- To ensure that changes to an API don’t break clients
- In CI/CD, to validate every change before deployment
Contract tests reduce the risk of “chain” regressions and make collaboration between different teams easier.
Golden tests: the gold standard of regression 🏅
Golden tests compare the software’s output with expected “golden” results. If something changes, the alarm goes off: sometimes it’s intentional, sometimes… it’s not. They’re useful for testing complex output, such as reports, configuration files, or structured API responses.
When to use them?
- When the output is hard to validate with simple assertions
- To ensure future changes don’t alter the format or content of results
- In projects where backward compatibility is essential
Update golden files only when you’re sure the change is intentional. A well-managed golden test is a valuable ally against silent regressions.
Smoke tests: turn it on and hope 🚬
Smoke tests are the fastest tests: they check that the system starts and basic functions respond. If they fail, better to know right away than to discover it in production. They’re often run after every deployment or build for an immediate check of the application’s health.
When to run them?
- After every deployment or build
- As the first step of a test pipeline
- To validate staging and production environments
Better to fail immediately than discover the problems in production! Smoke tests are your first shield against disasters.
Testing strategies and techniques 🛠️
Blackbox, whitebox, graybox: 50 shades of testing 🎲
- Blackbox: the tester knows nothing about the code, only what it should do. Ideal for those who love surprises (and bugs). It’s based on requirements and verifies the system behaves as expected, without worrying about how it does it.
- Whitebox: here you know everything, even the darkest secrets. Perfect for those who don’t even trust their own code. All logical paths, conditions, and loops are tested, to find bugs hidden in the depths of the software.
- Graybox: a compromise: you know something, but not everything. Like reading the documentation… but only the headings. Useful when you want to test some internal parts without losing the big picture.
Manual vs automated: who wins? ⚖️
- Manual: irreplaceable for usability, accessibility, and edge-case testing. But watch out: boredom and distraction are always lurking. Perfect for discovering problems only a human can notice (like Comic Sans font in production).
- Automated: the real strength of modern testing. Fast, repeatable, and above all they never complain (except when they fail at 3am). Ideal for repetitive regression, integration, and functional tests.
Continuous testing: the wheel never stops 🔄
Continuous testing is the key to preventing technical debt from piling up like unread emails. Every commit should trigger an avalanche of automated tests: if something breaks, better to know right away (and not on release day). CI/CD isn’t just a trend: it’s survival.
Test management and documentation 📚
Testlist, testbook, test report: paperwork never runs out 📝
- Testlist: the shopping list of tests. Useful for not forgetting anything (like milk, or the login test). Used to plan and track what has been tested and what’s missing.
- Testbook: the bible of tests: scenarios, expectations, expected results. Perfect for those who love bureaucracy (or for when QA asks awkward questions). Documents every test in detail, so no one can say “I didn’t know”.
- Test report: the final verdict. Green? Time to celebrate. Red? Time to cry (and investigate). Summarizes test results, highlights problems, and helps understand where to intervene.
Advanced approaches 🚀
TDD: Test-Driven Development (or how to write tests before the code) 🧪
TDD isn’t just for testing, it’s for designing better. Write the test, then the minimum code to make it pass, then refactor. It sounds like torture, but it saves you from sleepless nights and endless refactoring. And remember: TDD isn’t a religion, it’s a good habit. It helps you stay focused on the goals and avoid writing useless code.
BDD: Behavior-Driven Development (when tests tell stories) 📖
BDD turns tests into stories understandable even to those who don’t code. Scenarios are written in natural language, so even the PM can understand (and criticize) what the software does. Great for avoiding misunderstandings and “surprise” bugs.
In Go you can use libraries like godog:
|
|
Mutation testing: the test you don’t expect 🧬
Here mutations (changes) are introduced into the code to see if the tests catch them. If they don’t fail, maybe your tests are too gentle (or too distracted). It’s the best way to find out if your tests are really as “nasty” as they should be. In Go you can use go-mutesting, in Node.js there’s Stryker. These tools automate the creation of mutants and help you improve test quality.
Coverage: how covered are you really? 📊
Coverage indicates how much of the code is covered by tests. But watch out: chasing 100% can lead to over-testing, i.e. testing even the obvious and the useless. Better a “smart” coverage than a total but sterile one. Remember: quality isn’t measured only in percentage! Coverage only tells you where to look, not whether the code is truly bug-proof.
|
|
Flaky tests: the test that can’t make up its mind 🥴
A “flaky test” is a test that sometimes passes and sometimes fails without the code having changed. This unstable behavior undermines trust in the test suite and slows down development.
Common causes:
- Dependencies on external services or network resources
- Variable or non-isolated test data
- Timing, race condition, concurrency issues
- Tests that depend on execution order
- Lack of cleanup between tests
- Use of times, dates, or randomness without control
How to handle them:
- Isolate tests and use deterministic data
- Mock external dependencies
- Run tests in random order to uncover hidden dependencies
- Automate flaky test detection (e.g. by running the suite multiple times)
- Fix or remove unstable tests: better fewer reliable tests!
A flaky test is worse than no test: if you don’t trust the results, no one will really look at them.
Checklist of common test mistakes ⚠️
Even tests can have bugs! Here’s a checklist of common mistakes to avoid:
- False positives: the test passes even if the code is wrong (maybe because the assertion is too weak or missing entirely).
- False negatives: the test fails even if the code is correct (often due to wrong test data or setup).
- Fragile tests: they fail for reasons unrelated to the code (e.g. external dependencies, variable data, times, randomness).
- Over-mocking: too much is simulated, losing the sense of the real test and risking testing only the mocks.
- Duplicate tests: multiple tests do the same thing, increasing maintenance without added value.
- Slow tests: tests that slow down the pipeline and discourage frequent execution.
- Dependency between tests: execution order affects the result (tests must be independent!).
- Hardcoded test data: static data that doesn’t cover real cases or edge cases.
- Missing cleanup: tests leave dirty data or resources, causing side effects.
- Not updating tests: after refactoring or new features, tests aren’t updated and become useless or misleading.
Periodically review your tests with this checklist: test quality matters as much as code quality!
Conclusion 🎯
Testing isn’t just a matter of tools or percentages: it’s an act of love toward the future of your software (and toward whoever will have to work on it after you). Be creative, be rigorous, but above all… don’t let bugs feel at home! 🐞