Writing testable JavaScript code

Writing testable JavaScript code

Whether we use a testing framework like Mocha or Jasmine that works with Node, or we run DOM-dependent tests in a headless browser like PhantomJS, we have better ways to unit test JavaScript than ever before.

However, this doesn't mean that the code we want to test is as easy as our tools! Organizing and writing easily testable code takes some effort and planning, but we've found some patterns inspired by functional programming that can help us avoid those pitfalls when we need to test our code. In this post, we'll look at some useful tips and patterns to help us write testable code in JavaScript.

Keep business logic and display logic separate

One of the main jobs of a JavaScript-based browser application is to listen for DOM events triggered by the end user, and then respond to the user by running some business logic and displaying the results on the page. Where you set up a DOM event listener, it is sometimes tempting to write an anonymous function to do all this work. The problem with this is that in order to test the anonymous function, you have to simulate DOM events. This not only increases the number of lines of code, but also increases the time it takes to run the test.

Instead, write a named function and pass it to the event handler. This way, you can write tests directly against the named function without having to trigger a fake DOM event.

This doesn't just apply to the DOM. Many APIs in browsers and Node are designed to fire and listen for events, or wait for other types of asynchronous work to complete. As a rule of thumb, if you're writing a lot of anonymous callbacks, your code probably isn't going to be easy to test.

  1. // hard to test
  2. $( 'button' ). on ( 'click' , () => {
  3. $.getJSON( '/path/to/data' )
  4. . then (data => {
  5. $( '#my-list' ).html( 'results: ' + data. join ( ', ' ));
  6. });
  7. });
  8.   
  9. // testable; we can directly run fetchThings to see if it
  10. // makes an AJAX request without having   to   trigger DOM
  11. // events, and we can run showThings directly to see that it
  12. // displays data in the DOM without doing an AJAX request
  13. $( 'button' ) .on ( 'click' , () => fetchThings(showThings));
  14.   
  15. function fetchThings(callback) {
  16. $.getJSON( '/path/to/data' ). then (callback);
  17. }
  18.   
  19. function showThings(data) {
  20. $( '#my-list' ).html( 'results: ' + data. join ( ', ' ));
  21. }

Use callbacks or promises for asynchronous code

In the sample code above, our refactored function fetchThings runs an AJAX request, doing most of the work asynchronously. This means we can't run the function and test that it does what we expect, because we don't know when it will finish.

The most common way to solve this problem is to pass a callback function as a parameter to the function as an asynchronous call. Then, in your unit test, you can run some assertions in the passed callback function.

Another common and increasingly popular way to organize asynchronous code is to use the Promise API. Fortunately, $.ajax and most other jQuery asynchronous functions already return Promise objects, so it already covers most common use cases.

  1. // Hard to test; we don't know how long the AJAX request will take
  2.   
  3. function fetchData() {
  4. $.ajax({ url: '/path/to/data' });
  5. }
  6.   
  7. // Testable; we pass in a callback function and run assertions inside it
  8.   
  9. function fetchDataWithCallback(callback) {
  10. $.ajax({
  11. url: '/path/to/data' ,
  12. success: callback,
  13. });
  14. }
  15.   
  16. // Also testable: After the returned Promise resolves, we can run assertions
  17.   
  18. function fetchDataWithPromise() {
  19. return $.ajax({ url: '/path/to/data' });
  20. }

Avoid side effects

Write functions that take arguments and return values ​​that depend only on those arguments, like passing numbers into a math formula and getting a result. If your function depends on some external state (such as attributes of a class instance or the contents of some file), then you have to set up that state before testing the function, which requires more setup in the test case. You have to assume that the code you're running won't modify the same state.

Likewise, you should avoid writing functions that modify external state, such as writing to a file or saving data to a database. This will prevent side effects that will affect your ability to test other code. In general, it is best to keep side effects within your code, so that the "surface area" is as small as possible. For classes and object instances, side effects of class methods should be limited to the scope of the class instance being tested.

  1. // Hard to test; we have to set up a globalListOfCars object and a DOM structure called #list- of -models before we can test this code
  2.   
  3. function processCarData() {
  4. const models = globalListOfCars.map(car => car.model);
  5. $( '#list-of-models' ).html(models. join ( ', ' ));
  6. }
  7.   
  8. // Easy to test; we pass a parameter and test its return value without setting any global variables or checking any DOM results
  9.   
  10. function buildModelsString(cars) {
  11. const models = cars.map(car => car.model);
  12. return models.join ( ',' ) ;
  13. }

Using Dependency Injection

There is a common pattern in functions that can be used to reduce the use of external state, which is dependency injection - passing all external requirements of the function to the function through function parameters.

  1. // Depends on an external state data connection instance; hard to test
  2.   
  3. function updateRow(rowId, data) {
  4. myGlobalDatabaseConnector. update (rowId, data);
  5. }
  6.   
  7. // Pass the database connection instance as a parameter to the function; easy to test.
  8.   
  9. function updateRow(rowId, data, databaseConnector) {
  10. databaseConnector.update (rowId, data);
  11. }

One of the main benefits of using dependency injection is that you can pass in mock objects in your unit tests so that they don’t cause real side effects (in this case, updating a database row) and you only need to assert that your mock objects behave in the expected way.

Give each function a unique purpose

Breaking down a long function into a series of small, single-responsibility functions makes it easier to test whether each function is correct, rather than hoping that one large function does everything correctly before returning a result.

In functional programming, the act of stringing together several functions with a single responsibility is called composition. Underscore.js even has a function called _.compose that strings together a list of functions, passing each function’s result as input to the next.

  1. // Hard to test
  2.   
  3. function createGreeting( name , location, age) {
  4. let greeting;
  5. if (location === 'Mexico' ) {
  6. greeting = '!Hola' ;
  7. } else {
  8. greeting = 'Hello' ;
  9. }
  10.   
  11. greeting += ' ' + name .toUpperCase() + '! ' ;
  12.   
  13. greeting += 'You are ' + age + ' years old.' ;
  14.   
  15. return greeting;
  16. }
  17.   
  18. // Easy to test
  19.   
  20. function getBeginning(location) {
  21. if (location === 'Mexico' ) {
  22. return   '¡Hola' ;
  23. } else {
  24. return   'Hello' ;
  25. }
  26. }
  27.   
  28. function getMiddle( name ) {
  29. return   ' ' + name .toUpperCase() + '! ' ;
  30. }
  31.   
  32. function getEnd(age) {
  33. return   'You are ' + age + ' years old.' ;
  34. }
  35.   
  36. function createGreeting( name , location, age) {
  37. return getBeginning(location) + getMiddle( name ) + getEnd(age);
  38. }

Do not change the parameters

In JavaScript, arrays and objects are passed by reference, not by value, so they are mutable. This means that when you pass an object or array as a parameter to a function, both your code and the function that uses the object or array you passed have the ability to modify the same array or object in memory. This means that when you test your own code, you have to trust that none of the functions you call modify your objects. Every time you add some new code that can modify the same object, it becomes increasingly difficult to keep track of what the objects should look like, and therefore more difficult to test them.

[[177541]]

Instead, when you have a function that needs to use an object or array, you should treat the object or array as if it were read-only in your code. You can create new objects or arrays as needed and then align the fills. Alternatively, use Underscore or Lodash to make a copy of the passed object or array and then align the operation. Even better, use some tools like Immutable.js to create read-only data structures.

  1. // Modified the passed in object
  2.   
  3. function upperCaseLocation(customerInfo) {
  4. customerInfo.location = customerInfo.location.toUpperCase();
  5. return customerInfo;
  6. }
  7.   
  8. // Returns a new object
  9.   
  10. function upperCaseLocation(customerInfo) {
  11. return {
  12. name : customerInfo.name ,
  13. location: customerInfo.location.toUpperCase(),
  14. age: customerInfo.age
  15. };
  16. }

Write tests before coding

The process of writing unit tests before writing code is called test-driven development (TDD). A large number of developers find TDD very useful.

By writing the tests first, you force yourself to think about the API you are exposing from the perspective of the developer who will use your code. It also helps you ensure that you only write enough code to satisfy the test cases, and do not "over-engineer" the solution, introducing unnecessary complexity.

In practice, TDD as a discipline can be difficult to cover for all code changes, but when it seems worthwhile, it is a great way to ensure that all your code is testable.

Summarize

We all know there are some easy “gotchas” when writing and testing complex JavaScript applications, but I hope that by following these tips and reminders and keeping our code as simple and functional as possible, we can keep test coverage high and overall code complexity low!

<<:  Understand the real 『REM』 mobile screen adaptation

>>:  [Umeng+] Li Danfeng: Insight into the business secrets of big data from user behavior data

Recommend

Google AdWords detailed tutorial from account registration to advertising

If you want to advertise on your own cross-border...

Have you noticed that there are fewer and fewer fireflies around us?

Have you noticed that there are fewer and fewer f...

Lao Luo's two failures and Wang Ziru's three stupid moves

Luo Yonghao and Wang Ziru have been fighting and ...

Take you into the secrets behind the red envelopes in Moments photos!

The WeChat Moments photo red envelopes were in fu...

To do content operation, you must master these 3 core capabilities

1. What is content operation ? 2. Judgment Abilit...

"Heat shock" can also occur in winter, so be careful when taking a bath!

Audit expert: Peng Guoqiu Deputy Chief Physician,...

Real gems or "fake gems"? Talk about "gemstone improvement"...

Gem lovers all hope to buy a genuine, authentic a...

Brand Promotion and Marketing丨How to make users remember your brand?

You may remember two things: Because Fan Xiaoqin ...