Skip to content
Software Testing

Building Confidence in Every Change with Testing

14 min read
Building Confidence in Every Change with Testing
In this article

Testing usually feels straightforward when a project is still small because checking the main flows manually before a release or deployment can work for some time. As the application grows, this starts taking more effort because new features depend on existing behavior and more parts of the system begin interacting with each other, so a change that looks small can sometimes affect something that was working before.

This is where automated testing becomes useful! When changing business logic or refactoring a service or replacing an integration, I want some kind of feedback that tells me whether the existing behavior still works, especially when the change touches code that other parts of the application depend on. Testing gives that feedback in a repeatable way instead of relying on manually checking the same things after every change. BTW, passing tests still cannot prove that the software has no defects, but at least they give more confidence when making a change and help catch issues and problems before release or deployment.

Different tests look at the application from different levels. For example, unit tests usually focus on small pieces of logic, integration tests check how multiple parts work together, while end to end tests go further by following complete workflows closer to what actually happens when the application is used by users.

The testing pyramid

The testing pyramid gives a simple way to see how these test levels usually fit together, with many fast unit tests at the bottom, fewer integration tests in the middle, and a smaller number of end to end tests at the top.

The idea is usually known as Mike Cohn’s Test Automation Pyramid, while The Practical Test Pyramid gives another useful explanation of the model and its limitations.

The names of the layers are not fixed though, because Cohn’s original version used unit, service and UI tests, while today the exact names can differ between teams.

The basic idea is that there are usually many fast tests near the bottom, then the amount becomes smaller as the tests cover more of the application and become more expensive to execute. A project can therefore have a large amount of unit tests around business logic, then integration tests around important boundaries, while end to end tests are usually kept for workflows where several parts of the application need to work together.

I would not treat this as a rule because different projects naturally need a different balance. A backend library may depend heavily on unit and integration tests, while a web application with important browser workflows may need more end to end coverage.

The difference in execution cost is also important here because a unit test can usually run very quickly, while an end to end test may need to start the application and prepare test data before it can even begin the actual flow. It may also communicate with other services and use a real browser, so once the suite becomes large, the time needed to run it starts affecting the development workflow too.

Unit testing

Unit tests are usually where I would start when some logic can be checked independently. This could be a function or a class, while services and utilities can also fit well depending on how the application is structured.

When that unit depends on something external, mocks can sometimes replace those dependencies so the behavior can still be tested in isolation.

Imagine an ecommerce application where the final price is calculated after applying a discount. Testing that calculation directly is simple because the test can provide an input and check the result without starting the rest of the application.

I find unit tests especially useful around business logic because they are fast and failures are normally easier to locate. If the discount calculation suddenly returns the wrong value, the problem is usually somewhere close to that logic rather than somewhere across the whole application.

Their scope is intentionally small though, so the same calculation may work correctly while the checkout API endpoint sends the wrong value to it. The database could also return unexpected data, or another service may interpret the result differently.

Because of that, I see unit tests as a good way to protect isolated behavior, while broader tests become useful once different parts start communicating.

Integration testing

Integration tests become more interesting when the behavior depends on multiple parts of the system working together. The exact meaning of an integration test is not completely consistent between teams, so I find it more useful to be clear about which boundaries the test actually covers.

For example, instead of checking an order service by itself, an integration test could send a request through the API and then verify that the resulting order reaches the database correctly. This covers the communication between layers and can reveal issues that would never appear while testing the service alone.

This is where problems such as an incorrect database query or unexpected serialization can appear, while authentication middleware can also behave differently once the actual request passes through it. Another common case is when two modules work correctly by themselves but make different assumptions about the data exchanged between them.

Integration tests usually require more setup and take longer than unit tests, but they cover a different type of risk, which is why I find them useful around the boundaries between important parts of the application.

I found this meme while looking at integration testing examples and I think it explains the problem very well :D, unit tests can all pass while the integration between components is still broken or completely untested:

End to end testing

End to end tests go further because they exercise a complete workflow across the integrated system, usually through the same external interface a user or client would use.

For an ecommerce project, this could mean opening the website and searching for a product, then adding it to the basket before continuing through checkout until the order is submitted and the confirmation page appears.

Tools such as Playwright, Cypress and Selenium can automate this type of flow, which makes it possible to exercise many real parts of the application together.

The harder part appears when something fails because the reason can be somewhere in the frontend, while another failure may come from the backend or the database. Sometimes the application code is completely fine and the problem comes from the test environment or an external service.

For this reason, I prefer using end to end tests around important user flows instead of trying to represent every possible case through the browser. Checkout is a clear example, while authentication and account creation can also make sense when they are important parts of the product.

Other types of testing

Unit, integration and end to end tests cover a large part of everyday testing, although real projects often need other types depending on what the application does.

These are not all the same kind of classification. Unit, integration and end to end describe the scope or level of a test, while regression describes why testing is being repeated and security, performance and accessibility describe different quality areas that can be tested at several levels:

Testing areaMain purposeExample
UnitVerify isolated logicPrice calculation
IntegrationVerify components togetherAPI with database
End to endVerify complete workflowsCheckout process
RegressionVerify existing behavior after a changeBasket still works after checkout changes
Visual regressionDetect unexpected UI changesScreenshot comparison
PerformanceMeasure latency, throughput and scalabilityAPI under expected load
SecurityDetect security weaknessesAuthorization bypass
AccessibilityVerify accessibility requirementsComplete a flow using keyboard navigation
ContractCheck that systems agree on how they communicateFrontend and backend expect the same API request and response

The useful combination depends heavily on the project. An API used by other services or clients can benefit from contract testing because changes on one side can break the other, while a frontend with many visual states may get more value from visual regression testing. Security testing becomes especially important around authentication, authorization and sensitive data, while load and scalability testing become more relevant when latency, throughput or capacity are important requirements…

So I would choose these based on what can realistically go wrong in the application and how serious the impact would be if it happened.


Testing in CI

Automated tests become much more useful when they are part of CI because the same validation can run automatically whenever a change is proposed.

A simple pull request workflow can look like this:

When a pull request is opened, CI can run the same checks and return feedback before the change reaches the main branch, which is useful because the developer still has the change fresh in mind and the amount of modified code is usually relatively small.

The same issue can take much longer to understand after deployment because production logs may need to be checked before the problem can even be reproduced, then another release may be required after the fix and some failures may also leave data that needs to be corrected.

Code Coverage

I find code coverage useful as a signal, although the percentage by itself can easily give the wrong impression.

Coverage tells us which parts of the code were executed while the tests were running. Depending on the tool, this can include statement or line coverage, function coverage and branch coverage, which can help identify areas that receive little testing. The quality of the test still depends on what behavior it actually checks.

For example, a test can execute a function and never verify the returned value, so that function appears as covered even though incorrect behavior could still pass unnoticed.

Because of that, I would use coverage when looking for weak areas in the test suite rather than treating 100 percent as a target. If some important business logic has almost no coverage, that is useful information, while a very high percentage means much less when the assertions themselves are weak!

Mutation testing can give another useful signal here because it intentionally changes code and checks whether the tests detect the change. It is more expensive to run, but it can reveal weak assertions that normal code coverage may still count as covered.

What is mutation testing?
TL; DR: Mutation testing introduces changes to your code, then runs your unit tests against the changed code. It is expected that your unit tests will now fail. If they don’t fail, it might indicate your tests do not sufficiently cover the code.

https://stryker-mutator.io/docs

Risk-based testing

It is not always good to give every part of a project the same amount of testing effort because the risk can be very different, so how likely a problem is and how serious its impact is should both be considered.

For example, a formatting helper breaking may create a small visual issue, while a payment service failing can affect a real transaction. So, authentication and authorization deserve more attention for similar reasons, while checkout directly affects an important business flow and data migrations can cause much larger problems when existing data is changed incorrectly.

And this is usually called risk-based testing (RBT), where the risk helps decide how much testing effort an area should receive. It’s something like this:

I see this is a more practical way to decide where deeper testing is worth the effort, because the testing strategy follows the actual risk of the application instead of applying the same level everywhere.

Testability and architecture

Writing tests can expose architecture problems surprisingly quickly.

For example, imagine trying to test a small piece of business logic and discovering that several services have to be started first, then a database needs to be prepared and external APIs need to be configured before the test can run. I would start looking at whether too many responsibilities are coupled together at that point.

Clearer boundaries make the situation much easier:

With clearer boundaries, business logic can be checked independently while integration tests focus on communication with infrastructure, such as a database or an external service. The same idea can be applied to queues and other integrations.

This is why I prefer considering testability while the architecture is being designed, because writing useful tests later becomes much easier when the code already has boundaries that can be exercised separately.

Test maintenance and technical debt

A test suite changes together with the project, so maintaining it matters just as much as adding new tests.

I have seen how setup can slowly become duplicated while the suite takes longer to run as more scenarios are added, and some tests can also become too dependent on unstable environments. When this continues for long enough, developers can start losing trust in the results.

Flaky tests are a clear example because the same test may pass during one run and fail during another even though the relevant code never changed. Timing assumptions can cause this, shared state between tests can cause it too, while network dependencies and incomplete cleanup can introduce even more instability.

Test isolation matters here too because a test that depends on state left by another test can start failing depending on execution order, especially once tests begin running in parallel.

Once the normal reaction to a failed CI run becomes rerunning it until it passes, the feedback from that test has already become much less useful.

Because of that, I would keep reviewing and refactoring test code as the project grows, especially when setup becomes difficult to understand or the same testing logic starts being repeated in many places.

Building a testing strategy

For many applications, I would begin with fast tests around important business logic and then add integration tests around the places where different parts of the system communicate, while end to end tests can protect the important user journeys where the application needs to work correctly as a whole.

From there, other testing types can be added depending on the project and based on which problems are actually important there.

I would also keep CI feedback time in mind while building the suite because very complete testing can become frustrating when every small change waits a long time for feedback. Fast validations can run on every pull request, while slower checks can happen at another stage when that gives a better balance for the project.


Final thoughts

After working with testing in different projects, the part I value most is having confidence when changing existing behavior because this becomes even more important with faster development, especially now when AI is used more to generate, refactor and change code quickly, so manually checking every related flow becomes unrealistic very fast as the project grows.

I would rather have a smaller suite that runs quickly and gives reliable feedback than a huge suite that developers stop trusting, while with AI helping to produce changes faster, automated tests become even more useful because they give a quick signal that the existing behavior still works before those changes continue moving through the project.