• Labs icon Lab
  • Core Tech
Labs

Guided: Testing a Node.js and Express Application

This lab introduces learners to the methodology of Test-Driven Development(TDD) along with Unit and Integration Testing. Learners are given a partially complete Express CRUD application wherein they will be guided on writing and running tests along with implementing the features to pass those tests. Learners should be familiar with JavaScript and Express to fully grasp the context of this lab.

Labs

Path Info

Level
Clock icon Beginner
Duration
Clock icon 30m
Published
Clock icon Sep 09, 2024

Contact sales

By filling out this form and clicking submit, you acknowledge our privacy policy.

Table of Contents

  1. Challenge

    Introduction

    Overview

    In this lab, you will be introduced to the methodology of test-driven development(TDD) and how it can be employed through two of the most common testing methods: Unit testing and Integration testing. To do this, you will be utilizing the Mocha test runner framework along with the Chai assertion library. Some tests will elso employ Sinon to mock certain behavior and other will use Supertest.


    Application

    You have been provided with a nearly complete CRUD (Create, Read, Update, Delete) Express application to manage some generic products. For demonstration, a few initial routes, route handlers, and their respective tests have already been implemented. The rest have not, and you will be guided on how to do so.


    Further Details

    The data for this application is persisted through a JSON file called main-database located in the /data directory. The test-database JSON file will only be used for testing, which will be covered in a later step. If at anytime you wish to "reset" the data in main-database after you make changes, you can run the npm run seedItems command in the Terminal.


    Tips

    If you'd like to check your work, the solutions directory contains files for you to compare with. However, these solutions aren't the only valid solutions, so don't feel pressured to make your implementations exactly the same. What's important is that you understand what the tests are meant to do rather than writing them "correctly".

    Now let's continue on to TDD in the next step.

  2. Challenge

    Test-Driven Development

    Overview

    Test-Driven Development, or TDD, is a development methodology where tests are written before you actually implement any features, functions, or logic. Typically how this works is that you design a test case, or a scenario, for what a feature should or shouldn't do. It is important to always have a balance between robust tests that handle as many edge cases or possible flaws, but also isn't too rigid to the point where you must meet certain semantics to pass the test.


    After the test has been written, you will write the code needed to make sure the test passes. Then it becomes an iterative process where you will continue to refactor/improve your code(and perhaps even the test) while still continuing to pass the test(s). There will be an example of this for you to see in this lab, but for brevity's sake it won't be a recurring objective for the tests and functions you implement.


    Tests in this lab will include two of the most common types of tests, which are Unit Tests and Integration Tests. The next step will address Unit Testing.

  3. Challenge

    Unit Testing

    Overview

    Unit Testing is one of many testing techniques and is usually the most commonplace. It involves writing tests targeting individual components or functions in an application. For this lab, you will be working in the tests/UnitTests.test.js and controllers/dbController.js files for unit tests.


    Setting Things Up

    Open up the dbController file and take a look. The methods readData() and writeData() are already done as they relate to retrieving and storing data into the respective JSON file. The method getProductById() is already defined as it will be the example for you to look at. The rest of the methods will require you to implement. Now open UnitTests.test.js.

    Don't get too overwhelmed by everything here, as it will be explained to you piece by piece. The top has the usual imports from the chai assertion library and stubs for mocking from the sinon library. The describe functions are unique to the mocha testing framework and are used to define testing suite for a group of tests. In other words, think of it like a directory where each test case is a file in that directory. The it functions are the actual tests being run. The beforeEach()/afterEach() hooks and data stub variables aren't something you need to worry about. They are there to setup/mock data before each test case and to clean up afterwards. You need to mock data in the scenario because in unit testing, you don't want to modify the actual data in the database yet, just test that the functions themselves work as intended.


    The First Unit Test

    Now look at the first test case, identified as 'testGetID'. The getProductById() method defined in dbController uses readData(), but you don't need to read the data from the database, especially if you could end up accidentally modifying it. This is why the tests use the readDataStub, which will substitute the readData() call to return products data instead. Next, the test passes in 2 valid id values to getProductById() as well as a nonexistent one. If implemented properly, getProductById() should return the correct product when given valid id's and an undefined value if no product with the matching id is found. Lastly, it checks that the readDataStub has been called 3 times since the method uses readData() once each time it's called. Since it's called 3 times, readData() should have been called thrice. You can run this test in the terminal with the command npm test -- -g 'testGetID' and it should pass.

  4. Challenge

    Unit Testing Continued

    Next, let's look at the second test case, which is identified as testAdd. Like the previous test, it starts by defining some mock data and stubbing it in. It also defines some example products to be added. Then it calls addProduct and passes in one of the example products before checking that the outcome is as expected; the array should increase in size and the new product in the array should be what was passed in. Since the implementation of addProduct() should also utilize readData() and writeData(), it should also check that they are called and that writeData() is being called with products. The next section simply repeats this process with the second example product, but you will notice that it checks that the stubs are being called twice now instead once since you are doing the process again.


    Adding Products

    This unit test is complete and you can run it with the command npm test -- -g 'testAdd', but it should fail because addProduct() has not been fully implemented. Go to it in dbController.js and finish the method implementation.

    Instructions (1) * The `newId` has already been defined since this application will use an incrementing `id` value as a unique identifier. * The `product` parameter this method receives is an object that has a `name` and `quantity` property. * Use `push()` to add a new object to `products` such as `{id: newId, name: product.name, quantity: product.quantity }`. * Call `writeData()` and in `products`. Remember that like `readData()`, `writeData()` is asynchronous and is an instance method. * Run the test again and take notes of any feedback if the test fails, otherwise it should pass.

    Updating Products

    Head over to the testUpdate test case and you'll notice the initial data has already been created for you. You'll need to finish a unit test for updateProduct(). It borrows a lot from testAdd so use that test case as a reference.

    Instructions (2) * Call `updateProduct()` and pass in one of the updated products. * Check that the length of the `products` array is still the same(length of 5), and that the product at the corresponding index equals the corresponding `updatedProduct`. For instance, `updatedProduct1` corresponds to index 1. * Just like in the `addProduct()` test case, you should also check that the `readDataStub` and `writeDataStub` were called once and that `products` was passed into `writeDataStub`.

    At this point, you can optionally choose to do the same process again for the other updated product, or create another of your own and test it against the mock data. Remember from earlier that robust tests are always a balance between coverage and flexibility; if you don't cover enough edge cases then it would be too unreliable, but cover too much and the test becomes brittle and inflexible. At the very least, it would definitely be a good idea to do the test with the nonExistentProduct, as nothing should happen to the data when given the id of a nonexistent product.

    Instructions (3) * Call `updateProduct()` on the `nonExistentProduct`, then check that the `products` array length has not been changed. * Since `updateProduct()` always calls `readData()`, then `readDataStub()` should have been called accordingly. However, `writeData()` should not be called since there is no matching `id`, so it should only be called as many times as you tested before. * Now execute this test and it should fail because you still need to implement `updateProduct()`.

    Find the method updateProduct() in dbController.js. You will see that updateProduct() takes an updatedProduct parameter, which is an object that has and id property. Implement this method.

    Instructions (4) * Find the index of the product in `products` whose `id` matches the `id` of `updatedProduct` using the `findIndex` method as all the products have a unique `id`. * If a match is found, set the product at that index to `updatedProduct` and call `writeData()` with `products`. If no matching product is found, then result of `findIndex` will be -1 and you should not do anything. * Optionally, you could print out a console message that states this occurred. Now run the test again and it should pass. * If it does not, take note of the feedback from the test or take a look in the `solutions` directory.

    Deleting Products

    Lastly, head to the testDelete test case in UnitTests.test.js. You'll need to unit test and implement deleteProduct(). Much of it will be similar to the previous two unit tests you've implemented.

    Instructions (5) * Pick an item from `products` to delete and pass its `id` to `deleteProduct()`. * Then use `splice()` on `products` to delete the product from the local `products` array. For instance, passing in `3` as the `id` to `deleteProduct()` would require you to `splice()` from index 2. * Verify that the stubs have been called just like in previous tests, including the check that `writeDataStub` was called with `products`. * You can do this multiple times if you wish, but remember that checking your stub calls will increment for each repetition. You can also test for the same behavior with a nonexistent id as you did in `testUpdate`. * As before, run the test and it should fail.

    Now head back to deleteProduct() in dbController.js and implement it.

    Instructions (6) * This method takes an `id` parameter, which you can use with the `filter` method to create a new array consisting of all products whose `id` does not match this parameter. * If this new array does not match the original `products` array, update the JSON database with `writeData()` just like you did in `updateProduct()`. * Do not modify anything otherwise, though you can once again optionally print a message to the console if that is the case. * Now run your unit test once more and it should pass if `deleteProduct()` is implemented correctly.
  5. Challenge

    Integration Testing

    Overview

    With unit testing finished, you will now want to take a look at some integration testing. Integration testing is usually the next step in the hierarchy of testing. Whereas unit testing involves testing individual components or functions, integration testing involves testing the interactions between different components in the system. In the context of this application, integration testing would involve testing the route endpoints and the route handlers that are executed when the endpoint is called. As such, you will now need to work with the /tests/IntegrationTests.test.js, /controllers/ProductController.js, and /routes/products.js files.


    Routes

    Take a look at products.js. This is where all the route endpoints are defined, and calling them will trigger the associated route handler function. As you can see, there are already 2 routes defined for handling GET and POST requests to the same /create endpoint, which will execute their respective handler functions. You will need to define the routes and implement the handler functions for updating and deleting products.


    Route Handlers

    Next, head over to productController.js, where you will see 5 functions suffixed by whether they are GET or POST requests. These methods are the route handlers. The function product_create_get() just renders the create-update-form and passes in a title, while product_create_post() takes the provided data from the form and to add a new product to the database, before redirecting back to the home page.


    Finally, let's look at the integration tests in IntegrationTests.test.js. Much of it is the same as what you already saw in the unit tests, but this time you aren't stubbing anything for data. This is because you do want to test that the flow of data during interactions is happening as expected. However, you should never test against your real database, so the tests have been configured to test with the test-database.json file instead.

  6. Challenge

    Integration Testing Continued

    The first test is for GET requests to the /create endpoint, wherein it checks that it should be getting status code 200 OK for responses and that the text being returned contains the 'Create New Product' text that was passed to the title of the form. The second test, which tests POST requests to that same endpoint, sends an example product to the test database and then checks that it gets a status code 302 FOUND, redirected to the home page at the '/' route, and that the product added to the test database is the one was passed in. You can run either of these tests just like before with either npm test -- -g 'GET create' or npm test -- -g 'POST create' and they should pass.


    Test Implementations

    The GET update and POST update test cases for the /:id/update endpoints will be for you to implement.

    Instructions (1) * For the `GET update` test, make sure to check for a `200` status code right after the `response`. * Then check that `response.text` contains the lines `'Update Product'`, `'value=\"Product 2\"'`, and `'value=\"3\"'`. These are the expected values that should fill in the form when updating the product, as the product to be testing has the `id` of 2. * You can check for these lines in 3 different `expect()` calls using `to.include()`. * The `POST update` test flows almost the same logic as `POST create`, except remember that updating a product should not change the size of the data array and that the ID's are indexed at 1, not 0.

    The same should apply to the 'POST delete' test.

    Instructions (2) * Make sure to check for a `302` status code after the response, that the `response` was redirected to the `'/'` route for the home page, and that the database no longer has the deleted item. * This would mean that the data array would be 1 less from the original size, and you could further check by using the `find()` function to look for a product with the the deleted products `id`. * If it was deleted, `find()` should give you an `undefined`. * At this point in time, all these tests should fail because you have not yet implemented the routes and route handlers.

    Route Implementations

    First, define the routes in products.js. In a similar vein to the existing GET and POST requests to '/create', create routes for GET and POST requests to '/:id/update' as well as a POST request to '/:id/delete' all with their respective route handlers.

    Now return to ProductController.js and implement the last 3 route handlers.

    Instructions (3) * For `product_update_get()`, simply call `res.render()` the same way as `product_create_get()`, except you should pass in `Update Product` as the `title` along with `productToUpdate` as the second property in the object. * For `product_update_post()`, pass in `productToUpdate` to `updateProduct()` in the same way as seen in `product_create_get()`. * Then redirect back to home with `'/'`. Do the same for `product_delete_post()`. * If done correctly, running the integration tests you just implemented should pass. If not, try and see what the test feedback returns to troubleshoot.
  7. Challenge

    Wrapping Up

    If all your tests are now passing, you can run the application with npm run serverstart and check out the web browser.

    Remember you can always reset the data in your main-database.json, which is what the application serves, using npm run seedItems. Now that the lab is complete, feel free to experiment with the tests or the application itself.

George is a Pluralsight Author working on content for Hands-On Experiences. He is experienced in the Python, JavaScript, Java, and most recently Rust domains.

What's a lab?

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Provided environment for hands-on practice

We will provide the credentials and environment necessary for you to practice right within your browser.

Guided walkthrough

Follow along with the author’s guided walkthrough and build something new in your provided environment!

Did you know?

On average, you retain 75% more of your learning if you get time for practice.