Taha

Introduction to testing

All programmers do testing the very first time they write code in their lives. After you add or modify some line of code, you head to the browser to see the changes. Sometimes, you want to check the new feature you added, and other times you just want to see if something broke after you modified something in your code.

That kind of testing is called manual testing.

If something is called manual, it means there should be an automated version. And there is, and it's called automated testing.

With automated tests, you don't need to go manually testing your changes in the browser most of the time; there should be a command you run to do all the testing for you.

It's easier said than done. That command is something you need to implement; and here comes the other aspect of learning programming, which is learning to write tests.

Functional vs. Non-functional tests

In this article I'm talking about functional tests. Functional tests are the tests that ensure that your code is working as expected. It's testing the behavior of your software—does it do what it should do?

Non-functional tests, on the other hand, are for testing the other aspects of the software, such as testing performance and security.

Great benefits of writing automated tests

The two biggest benefits of writing automated tests are: detecting bugs and refactoring.

Detecting bugs is basically checking if you broke the code after certain changes—the same reason you do manual testing.

Refactoring is also checking if you broke the code but after improving the code. There are many times where you think about improving the design of the code, but you are reluctant to do that because you might break something. With good tests, you don't have to worry about this because you will know if you broke something while refactoring it.

So a common thing developers with tests would do is writing the code the fastest way possible just to make the tests pass, and then refactoring it to make it look better.

Types of tests

If you had ever read anything about testing, it's likely that you have come across these terms: unit testing, integration testing, end-to-end testing, acceptance testing, and the list goes on and on.

Interestingly, each of these types tends to have a different definition. Explaining each one in detail will require a whole article for each one. But in my experience, understanding the following three types of tests the way I (and many others) look at them will save you a lot of time and confusion in the future.

Unit tests

Unit tests are the most basic type of tests. They form the majority of the tests in your code. Some see units as the low-level stuff in your code, like objects and functions. Others, including me, see each unit as a single test that tests some specific behavior in the software. That test might use multiple objects and functions to achieve some goal.

An example would be a test that tests the parsing of CSV string. That test might use multiple objects and functions to achieve that, but it's still a single test that tests a behavior of my software.

Integration tests

Integration tests test the integration between two standalone parts of the system. Some look at integration tests as a way to test the integration of multiple objects and functions in the same system. Others, including me, look at it as the integration between the software's code and other services.

An example would be testing the integration between your app and a third-party payment service. In this case, your app works alone, and the payment service works alone, but does the integration between them work as expected in the context of your app?

End-to-End tests

If unit tests are for testing the internals of your software, and integration tests are for testing the integration with other parties, how can you test if your whole app works as a whole?

That's what end-to-end tests are for. They are the slowest kind of tests. They should run the same way your user would use the app: go to a specific url with some input, and then see something on the page (or get a specific response from that endpoint).

There are tools for that kind of testing. These tools can literally open a new instance of the browser, go to a specific url, and inspect the results on the screen—you can literally see that process happening in front of you, unless you are using headless browser testing.

The speed of tests

The speed of your tests are based on the type of tests you are running and how they are implemented. They can be as fast as a few hundred milliseconds or as slow as several hours.

Unit tests are usually the fastest because they don't need to wait for a response from some external server or run the app in the browser.

Integration tests can be as fast as unit tests or can be slower. This depends on how you implement them: if you are mocking the third-party service (I'll talk about mocks in a little bit), then they will be fast. If you don't mock them, then the speed depends on the performance of that third-party interaction.

End-to-end tests are the slowest because they need to run as if the app is in production. You need to wait for requests and third-party services to complete before seeing the results of your tests.

A good practice is to split your test suites in types so you can run each one separately. A general rule of thumb is to run unit tests on every change because they are the fastest; run integration tests multiple times in an hour; run end-to-end tests a couple times per day to make sure that the whole system works as expected.

Mocking

Mocking is a very a big topic if I want to get into its details. But in its essence, mocking is replacing an element in your code (like object or function) with a fake version.

There are multiple reasons why you would need to do that. One reason is performance. If your tests need to talk to an external service, you can replace that external service with a mock so you don't have to make an actual request, and instead use the mock to get some response.

Some people use mocking for isolating the thing they are testing from the rest of the app—this is called the Mockist testing style. Some people don't like this approach, but it's a valid one used by many great developers.

As I mentioned above, getting into the details of mocking is beyond the scope of this article. So all you need to know is that mocking is a fake version of some element in your tests—most testing tools support creating them.

Test-driven development

You probably heard the term TDD (Test-driven development). TDD is just one way to write and run your tests. From its name, it's a way to derive your code through tests, which means you write tests first and then the code to make it pass.

It might feel awkward at first, but it's actually a good thing to do for two main reasons. Writing the test first ensures you have a test for each code you implement—if you don't do this, you might forget to write the test after writing the actual code, or might miss some cases.

Another reason TDD is good is because it forces you to think how your code will work and look like before implementing it, which would help you make better upfront design decisions—some find this true and some don't, but personally I find it useful in that regard.

TDD might not be for everyone. You don't have to use it if you don't like it. I think of it as one style of writing tests.

Tools for writing tests

I don't think the tool is the most important thing here. The JavaScript ecosystem has a good number of testing tools, like Jest, Mocha, or Vitest, which is what I use these days.

The above tools are mainly used for unit and integration testing. At the time of this writing, Cypress and Playwright are the most popular tools for end-to-end testing.

Any of these tools would do the work. The important thing here is to learn how to write good tests—and that only comes with practice.

Code coverage

Code coverage is the percentage of how much your code is covered with tests. Some aim for 100% coverage. I find this almost impossible to have; instead, you should aim to cover the critical, major parts of the behavior of your software.

For example, you don't have to test all the getters and setters of an object if they work as simple getters and setters. Instead, test the thing that really matters to how your software pieces should behave. For example, test a setter of an object if it runs some validation before setting a value.

So the question of how much coverage I should have in my tests is not easy to answer. It usually depends on what phase the software is in and what the software is really trying to do.

A simple example

My goal in this article is not to show you how to write tests (I might have more articles on these in the future), but rather to show you the whats and the whys of testing.

Having said that, I think it would be a good idea to show you a quick example of what testing looks like in the code.

In this simple example, I'll write a function that adds two numbers (the classical example of testing), and I'll write a test for it.

I'll use Vitest in this example, but you can use whatever tool you like.

You can check their docs to see how to install it. After you install it, create a new test file called calculator.test.js.

import { describe, it, assert } from 'vitest'
import { add } from './calculator'

describe('calculator', () => {
  it('add two numbers', () => {
    assert.equal(add(2, 2), 4)
  })
})

This code tests a function called add in a module called calculator. That function takes two arguments to add. So, it tests if adding 2 and 2 returns 4.

To make it pass, you have to create the calculator module—see how I wrote the test before the code, that's basically what TDD is.

export function add(a, b) {
  return a + b
}

Now, if you run the test it should pass.

You can check the docs of the tool to see how to run the tests. In my example with Vitest, I had to add a new script to package.json called test. And when I run npm run test, it would run all of my tests.

Conclusion

Testing is an important part of writing good code. With it, you can detect bugs and refactor your code. Not only that, but it also helps you think about how your software should behave, especially when you use TDD.

Learning how to write good tests only comes with practice. It's okay if you find it difficult to test something at first, but if you keep practicing, you will eventually find your own testing style and what works and what doesn't.

Taha Shashtari

I'm Taha Shashtari, a full-stack web developer. Building for the web is passion. Teaching people how to do that is what I like the most. I like to explore new techniques and tools to help me and others write better code.

Subscribe to get latest updates about my work
©2024 Taha Shashtari. All rights reserved.