Unit Testing on any UI Project
Unit Testing is one of those aspects of a UI project that is often ignored. Not because nobody wants to do it, just that the cost of setup and maintenance can be taxing for a small team. Even on a large team, it can be ignored during those looming deadlines. Testing takes far greater discipline and rigor to do it right and to keep doing it right.
This post is not a panacea for Unit Testing but rather distilling the process of Unit Testing to a few key areas. Also, note that we are just focusing on Unit Testing and not on other kinds of testing like Integration (aka end-to-end), Acceptance, Stress/Chaos testing, etc. I’ll also limit myself to Web UI but the ideas are applicable to other UI platforms.
I’ll get it out right now and state it boldly:
Unit Testing is Hard
With familiarity, it will become easier, but it is still a multi-step process before you are in cruising mode.
Why is it hard?
1 Setup can be challenging:
- You have to first pick a testing framework such as Jasmine, Mocha. You may also have to pick other libraries for framework specific testing.
- Pick a build tool like Gulp, Browserify, Webpack or plain NPM.
- Configure the build to run in test mode
- Setup harness like Karma and related plugins
- Setup Coverage and reporting
- Makes sure it works on your Continuous Integration Server such as Jenkins, or TeamCity
On every project I worked on there was always a bit of fiddling with the settings and doing things differently based on the available infrastructure.
2 Devising a test for certain scenarios can be hard. This can require lot of mocking or changing the code to become more explicit about dependencies. This is probably the best part of testing but can be demotivating sometimes. The end result can be cleaner code and most likely results in better understanding.
3 Sometimes it makes more sense to do
Test-Driven development instead of
Test-First but requires more experience to know when to pick between the two.
A Test-First approach may not be as rewarding and has a longer lead-time to gratification.
Instead, seeing working code with manual testing can be more gratifying. Adding unit-tests at that point will also be more meaningful.
On a separate note,
Test First Development works great when doing API Design.
4 Once you are in the middle of the project, a breaking test can result in lot of investigation. This is part of the pain of software development but manifests more with a failing test! Of course, those same tests will help you later when doing serious refactoring.
The above is a decent real-world representation of what you have to go through. At least I’ve never had a
silk road experience yet :-)
Luckily things will get brighter. As you do more Unit Testing, you will start seeing the patterns emerge. After a few projects doing testing, you will realize the repetition that is happening. It will be the same kind of tests being done, possibly with different frameworks or libraries. The key is that the types of tests are quite limited. The following is a representative list of all the possible unit-tests you will ever write:
- Algorithmic / State-based
- Form Validation
- Network related
- Time/Clock based
- Async testing
1. Algorithmic / State-based tests
These tests are purely logic and have no UI involvement. Most likely these tests focus on the business-logic
of your application. For example, you may have a very specific way of parsing the JSON payload from a network request.
In this case, you will have a separate module, say
parseEntity.js that knows how to consume this payload
and make it usable on the client side.
The kind of tests you will write will include the following:
- Parsing for empty payloads
- Parsing for really large payloads
- Parsing for malformed payloads
- Parsing for various types of payloads
As you can see, its purely logic based and runs through a variety of situations to ensure the parsing module
produces the correct results. There is one clear advantage to doing Algorithmic tests, which is they can
all be done as data-driven tests. You can define a giant list of
output cases and simply
run through them. You can even keep this list separately in a JSON file or even a Database! As you find
more edge-cases, you can craft a specific data-test, include in your list, and ensure your module works correctly.
Other examples of logic-based testing could be for:
• Mathematical Calculations
• Data transformations (such as map, sort, filter, group)
• Search algorithms, Regex
• Correct translations of strings based on locale
• Testing services used in the app (eg: persistence, preferences)
2. Form Validation tests
Technically this type of test falls under the Algorithmic category. However this use case is quite frequent on UI apps that it demands its own category. Here again you can have a data-table for all the input-output pairs. The types of things you will test include:
- Validation of field values against a set of constraints. This can be encoded as a data-driven test.
- Testing for the proper error messages for failed validations
- Testing for any success messages for successful validations
- UI feedback for messages and errors
- Ensuring various form field are in the correct state (visibility, enabled, etc.)
3. UI interaction tests
This category is probably the easiest to explain. These tests are for the UI components of your
application where you test the behavior and visual feedback. For example, if you had a
you would have tests such as:
- Ensuring the textbox has a placeholder text
- Search button is disabled if there is no text
- On focus, the style of the textbox changes
- Entering text, enables the search button
- Hitting the
Returnkey fires the search callback. Same holds for clicking on the search button.
These tests can also get very tricky for certain scenarios. For example, drag-and-drop is not an easy one. So is
testing for a combination of hot-keys and mouse/touch operations. For such tests, its probably best to wrap the core logic
service and expect the service to change the internal state correctly. By reducing these user interactions to
some known, expected state, you can simplify your testing. It essentially becomes a state-based test at that point.
If you rather test this more explicitly, you can simulate events via
jquery and check if the callbacks are getting
fired. Of course, you also need to check if the correct state is being reflected via proper visual feedback.
4. Network related tests
These tests can also be treated as service-tests. Usually the network related activity is performed by the data-layer of your application, usually wrapped as a service. Making a real network request is not a responsibility of the Unit Test, neither is it feasible. This is where you will mock the network backend and ensure the proper call and parameters are being sent.
This test usually involves firing a service method and ensuring the proper network request is being made. You can also mock the response to return valid / error payloads and ensure the service layer behaves as expected.
5. Time based tests
If you have some functionality in your app where you are relying on
setInterval(), you have to do a time-based test.
However simulating or even waiting for the specific period is not feasible as it can slow down your tests. This is the case for
“Mock the Clock”! Yes, literally. Before you can run the test you have to hijack (normally via a library) the
mock will provide methods to advance the clock by the required time. This is the way to forward time in your test. At this point
you can check your behavior to see if it has performed the required set of operations.
6. Async testing
Most UI code these days relies on
callback-based asynchronicity. This requires some change in the way you test the functionality.
All testing libraries run synchronously, so they provide hooks for your test to signal back (with a
done callback) when it is ready.
Once you signal
done, the test will check the expectations and pass / fail the test.
Mechanics of a Unit Test
Every unit test follows a standard 3-step process:
- Prepare the context and environment for the test
- Run the test code
- Assert things are working as expected
Libraries (Mocha, Jasmine) will provide you APIs for these 3 parts of testing. The popular libraries follow a Behavior-Driven approach. This means the API is more english-like and focuses on User-level behavior for the test cases.
describe- used to create a test-suite or a group of test-cases
it- run a single test-case
before- called once to setup the context for the test suite
after- called after completion of the test suite
beforeEach- called before each test case
afterEach- called after completion of each test case
The important thing to note in BDD frameworks is that you can nest the
it. This creates nested context so the inner-most
it accumulates the state from all of the parent
To help you during the test execution part, there are few more helpers that you can use. These include:
- Mocks: Help you simulate time-consuming dependencies like Network, Database, Services, etc.
- Stubs: Help you provide canned responses for certain API dependencies
- Spies: Help you spy on dependencies to ensure they are getting called correctly and in a timely fashion
Finally the aforementioned libraries also have APIs to assert your expectations for a test. These assertions will result in passing or failing the test. This is the last part of the test, where you check that the behavior was correctly performed and as per expectations.
There are also specialized libraries (Chai) that offer more fluent-APIs for performing assertions.
Principles of good Unit tests
Irrespective of the kind of tests you write, there are some golden rules to adhere for all Unit Tests. Violating these rules will only make it difficult to scale your codebase as you add more features. They will also degrade your overall Developer Experience. So always strive to meet these rules!
- Should run fast. Use mocks where necessary to speed up slow running dependencies (eg: Network)
- Should be isolated and run independently
- Keep the assertions limited and focused. Create separate tests if the assertions are different.
- Give very specific names for your tests. Good naming is monumental.
- Do not test library code, even if done indirectly!
- Test the happy path
- Test the boundary conditions
- Test the failure conditions
Testing is hard and takes experience to get it right. By following the above principles and remembering that: “there are only a limited types of tests”, you can keep the pain of testing to a minimum.
Fun (MTF) :-)