How Devs Can Write Testable Apps

    How Devs Can Write Testable Apps


    Article summary

    Document details

    Purpose

    To give some ideas on how developers can make their code easier to test and so catch issues earlier and quicker.

    Audience

    Developer and testers

    Product quality is the responsibility of everyone in the development process.  In order for testers to be able to efficiently test and set up automated tests, they need developers to make sure that their code is testable. Indeed, when developers are active participants in the testing process by running automated tests set up by testers, then potential issues can be spotted and fixed quicker.

    So as a developer you need to help the testing team create these automated tests by writing code that is easy to create these tests for. Then they can help you by creating easy to run automated tests that you can use to spot bugs early.

    You don’t want to be the developer who broke the overnight build or introduced the critical bug that wiped the records of your customers…

    Core Principles for Testable Applications

    Writing testable applications involves several core principles to promote maintainability, modularity, and clarity.

    Separation of concerns: break down the application into distinct, independent components to allow for isolated testing. This can involve following design patterns like MVC (Model-View-Controller) or layering architecture to decouple logic.

    Single responsibility principle: to ensure that each component or function has a clearly defined role, simplifying the creation of focused and precise tests.

    Dependency injection: instead of using hard-coded dependencies, injecting them will enable mock testing and foster flexibility.

    Interfaces instead of concrete implementations: allows components to be substituted with mocks or stubs, making testing easier.

    Consistent and clear APIs: minimising side effects ensures predictability, making tests more reliable and easier to maintain over time.

    Best Practices

    Add locators: Developers have the power to add locators, or IDs, when developing a web page. This can make finding a unique ID for testing much easier when automating tests. The IDs should be unique on a given page so it can be easily located by the automation. Avoid creating IDs whose values are dependant on screen position, as a position change will break automated tests. For example, table cell coordinates can change, so instead of using the placement as the ID, perhaps use the ID of the table’s data itself (perhaps a UID). Lastly, using a consistent syntax will help testers locate IDs across web pages.

    Use flags for State Identification: Simplify automated testing by flagging states to handle asynchronous actions. One way of achieving this is to add a custom hidden element on the page that changes state as asynchronous actions are completed (like a dialog box being presented). Automated tests can then check for these changes to resume executing a test.

    Write small, focused functions: Keep functions short and focused on a single task, making it easier to isolate behaviour during testing.

    Use dependency injection: Pass dependencies into classes or functions instead of hard-coding them, allowing for easier substitution with mocks or stubs in tests.

    Favour interfaces or abstractions over concrete implementations: This enables you to swap out real implementations with test doubles (mocks, stubs, fakes) during testing.

    Reduce side effects: Limit side effects by making functions pure (same input always gives same output), which makes them easier to test.

    Avoid global state: Global variables or singletons can make code harder to test because they introduce hidden dependencies. Use local state and pass dependencies explicitly.

    Use descriptive names: Clear, meaningful names for methods, classes, and variables improve code readability and make it easier to understand during testing.

    Write loosely coupled (decoupled) code: Ensure that components or modules are independent of each other, so they can be tested in isolation. For example, for testing low frequency alerts, it should be possible to easily pass in data that would trigger the alert.

    Modularise your code: Break down the application into small, reusable modules. This allows testing for specific parts without relying on the entire system.

    Structure components to be reusable: Creating and utilising reusable components reduces the amount of code and number of individual tests needed.

    Use mock objects for external systems: Avoid testing actual integrations with databases, APIs, or other systems. Use mock objects to simulate external behaviour during testing.

    Ensure consistent and stable APIs: Consistent APIs make it easier to write tests that expect predictable outputs, reducing testing complexity.

    Refactor for testability: If code is difficult to test, refactor it by decoupling tightly coupled components, removing dependencies, or improving structure.

    Apply Behaviour-Driven Development (BDD) Practices: A clear understanding of the system's behaviour and its components is crucial. By knowing how changes affect both the component and its boundaries, you can better define expected outcomes. This helps ensure that the system behaves as intended and that boundaries between components remain well-defined. For example, modifying an API interface requires not only introducing new parameters but also understanding how these changes might affect the system’s overall behaviour and its integration points. Maintaining an updated architecture diagram allows you to clearly see how different components interact and what downstream elements may be influenced.

    Have up to date Architecture diagrams: Update them with any new changes and clearly state any external dependencies. This should be a model of your system. Show the internal as well as the external boundaries between different components and the dependencies that each component has. This will help you: to understand how component changes will affect other components and so target tests; and make sure that you have continuous testing across component boundaries.  These boundaries should be incorporated into your testing strategy, so that components and boundaries can be tested individually and then as part of acceptance tests.

    API-First Approach

    API tests will be easier to set up and much faster to run than a UI test. Additionally, APIs can be tested earlier in the development stage, to ensure that the major bugs like accidental deletion or overwriting are checked for, even before the UI is created.

    Having APIs directly available for testing, rather than just having to use the UI, will make your testing teams efforts much more efficient. Even if the API is not publicly available, having the API internally available to testers will allow them to test with a lots of different data to catch edge case issues.

    Of course, certain tests will need to be executed with the UI. However, these can be a more limited subset that specifically targets the UI’s functionality, rather than testing the behavior of integrations or services.

    Design Considerations

    Automation-friendly architecture

    Consider how much data is required to be already present in order to carry out an action.

    Mitigate process dependencies in your tests

    If one process depends on the finishing of another process that is long running, then this will cause difficulties in testing, for example updating customer records after the run of a batch import.

    Consider ways that data could be added immediately, in order to test the dependant process. So that changes to this dependant process can be instantly tested when it is changed, rather than having to wait for a long end-to-end test and bugs picked up quicker.

    Common Pitfalls & Solutions

    Frames can make automated tests difficult, so think about whether they are really needed, before using them.

    Managing batch processes and dependencies: use flags for State Identification.

    Date pickers:  Allow the date to be entered as text as well as using the date picker, so that it is easier for automation.

    Fragile or dynamic IDs are one of the most common reasons why automated tests become unreliable. To prevent this, developers can intentionally include unique IDs into their web pages.

    Further reading

    https://engineering.kablamo.com.au/posts/2020/test-ids-always/

    https://www.testrail.com/blog/3-tips-test-dev-collab/