This is a comprehensive guide to providing reliability in JavaScript and Node.js. Here are collected dozens of the best posts, books and tools.
First, deal with generally accepted testing methods that underlie any application. And then you can delve into the field of interest to you: frontend and interfaces, backend, CI or all of the above.
What to do. The test code is different from what goes into operation. Make it as simple as possible, short, free from abstractions, single, wonderful at work and thrifty. The other person should look at the test and immediately understand what he is doing.
Our heads are busy with production code, they do not have free space for extra complexity. If we shove a new portion of complex code into our poor mind, this will slow down the work of the entire team on the task, for the sake of which we are testing. In fact, because of this, many teams simply shun tests.
Tests - this is an opportunity to get a friendly and smiling assistant, with whom it is very good to work and which gives a huge return on small investments. Scientists believe that in our brain there are two systems: one for actions that do not require effort, such as driving on an empty road, and the second for complex operations requiring awareness, such as solving mathematical equations. Create your tests for the first system, so that when you look at the code you get a feeling of simplicity, comparable to editing an HTML document, and not with a 2X(17 × 24)
solution 2X(17 × 24)
.
This can be achieved by carefully selecting the methods, tools and goals for testing, so that they are economical and give a large ROI. Test only as much as necessary, try to be flexible. Sometimes it’s worth dropping some tests and sacrificing reliability for the sake of speed and simplicity.
Most of the recommendations below are derived from this principle.
Ready?
Section 1. Anatomy of the test
1.1 The name of each test should consist of three parts
What to do. The test report should indicate whether the current revision of the application meets the requirements of those people who are not familiar with the code: testers involved in the deployment of DevOps engineers, as well as yourself in two years. It will be best if the tests report information in the language of requirements, and their names consist of three parts:
What exactly is being tested? For example, the ProductsService.addNewProduct
method.
Under what conditions and scenarios? For example, the price is not passed to the method.
What is the expected result? For example, a new product is not approved.
Otherwise. The deployment fails, the test called “Add product” fails. Do you understand what exactly works wrong?
Note Each chapter has a code example, and sometimes an illustration. See spoilers.
Code examples
How to do it right. The name of the test consists of three parts.
//1. unit under test describe('Products Service', function() { describe('Add new product', function() { //2. scenario and 3. expectation it('When no price is specified, then the product status is pending approval', ()=> { const newProduct = new ProductService().add(...); expect(newProduct.status).to.equal('pendingApproval'); }); }); });
1.2 Structure the tests according to the AAA pattern
What to do. Each test should consist of three clearly separated sections: Arrange (preparation), Act (action) and Assert (result). Adhering to such a structure ensures that the reader of your code does not have to use the brain processor to understand the test plan:
Arrange: all the code that brings the system to state according to the test scenario. This may include creating an instance of the module in the test designer, adding records to the database, creating stubs instead of objects, and any other code that prepares the system for the test run.
Act: code execution as part of a test. Usually just one line.
Assert: make sure that the resulting value meets expectations. Usually just one line.
Otherwise. You will not only spend long hours working with the main code, but your brain will swell also from what should be a simple job - from testing.
Code examples
How to do it right. A test structured according to the AAA pattern.
describe.skip('Customer classifier', () => { test('When customer spent more than 500$, should be classified as premium', () => { //Arrange const customerToClassify = {spent:505, joined: new Date(), id:1} const DBStub = sinon.stub(dataAccess, "getCustomer") .reply({id:1, classification: 'regular'}); //Act const receivedClassification = customerClassifier.classifyCustomer(customerToClassify); //Assert expect(receivedClassification).toMatch('premium'); }); });
An example of antipattern. No separation, in one piece, is more difficult to interpret.
1.3 Describe expectations in the language of the product: state in BDD style
What to do. Programming tests in a declarative style allows the user to immediately understand the essence without spending a single brain processor cycle. When you write imperative code packed in conditional logic, the reader has to make a lot of effort. From this point of view, you need to describe expectations in a human-like language in a declarative BDD style using expect / should and not using custom code. If in Chai and Jest there is no necessary assertion, which is often repeated, then you can expand the matcher Jest or write your own plugin for Chai .
Otherwise. The team will write fewer tests and decorate annoying tests with .skip()
An example of antipattern. To understand the essence of the test, the user is forced to get through a rather long imperative code.
it("When asking for an admin, ensure only ordered admins in results" , ()={ //assuming we've added here two admins "admin1", "admin2" and "user1" const allAdmins = getUsers({adminOnly:true}); const admin1Found, adming2Found = false; allAdmins.forEach(aSingleUser => { if(aSingleUser === "user1"){ assert.notEqual(aSingleUser, "user1", "A user was found and not admin"); } if(aSingleUser==="admin1"){ admin1Found = true; } if(aSingleUser==="admin2"){ admin2Found = true; } }); if(!admin1Found || !admin2Found ){ throw new Error("Not all admins were returned"); } });
How to do it right. Reading this declarative test is straightforward.
it("When asking for an admin, ensure only ordered admins in results" , ()={ //assuming we've added here two admins const allAdmins = getUsers({adminOnly:true}); expect(allAdmins).to.include.ordered.members(["admin1" , "admin2"]) .but.not.include.ordered.members(["user1"]); });
1.4 Follow Black Box Testing: Test Only Public Methods
What to do. Testing the insides will lead to huge overhead and will yield almost nothing. If your code or API provides the correct results, is it worth it to spend three hours testing how it works internally and then supporting these fragile tests? When you check public behavior, you simultaneously implicitly check the implementation itself, your tests will fail only if there is a specific problem (for example, incorrect output). This approach is also called behavioral testing. On the other hand, if you are testing the internals (the “white box” method), instead of planning the output of the components, you will focus on small details, and your tests may break due to small alterations of the code, even if the results are okay, but escort will take much more resources.
Otherwise. Your tests will behave like a boy shouting “Wolf!” : Loudly report false positives (for example, the test fails due to a change in the name of a private variable). It is not surprising that soon people will begin to ignore CI notifications, and one day they will miss a real bug ...
Code examples
An example of antipattern. testing the insides for no good reason.
classProductService{ //this method is only used internally //Change this name will make the tests fail calculateVAT(priceWithoutVAT){ return {finalPrice: priceWithoutVAT * 1.2}; //Change the result format or key name above will make the tests fail } //public method getPrice(productId){ const desiredProduct= DB.getProduct(productId); finalPrice = this.calculateVATAdd(desiredProduct.price).finalPrice; } } it("White-box test: When the internal methods get 0 vat, it return 0 response", async () => { //There's no requirement to allow users to calculate the VAT, only show the final price. Nevertheless we falsely insist here to test the class internals expect(new ProductService().calculateVATAdd(0).finalPrice).to.equal(0); });
1.5 Choose the right imitated implementation: avoid fake objects in favor of stubs and spies
What to do. Simulated implementations (test doubles) are a necessary evil, because they are associated with the internals of the application, and some are of great value ( refresh the memory of imitated implementations: fake objects (mocks), stubs (stubs) and spy objects (spies) ) However, not all techniques are equivalent. Spies and stubs are designed to test requirements, but have an inevitable side effect - they also slightly affect the insides. And fake objects are designed to test the insides, which leads to huge overhead, as described in chapter 1.4.
Before using simulated implementations, ask yourself the simplest question: “Do I use this to test functionality that has appeared or may appear in the documentation with requirements?” If not, it smacks of testing using the “white box” method.
For example, if you want to find out if the application behaves as it should when the payment service is unavailable, you can make a stub instead and return “No answer” to check if the module under test returns the correct value. So you can check the behavior / response / output of the application in certain scenarios. You can also confirm with the help of a spy that when the service was unavailable, the letter was sent, it is also behavioral testing, which is better reflected in the documentation with the requirements ("Send a letter if payment information cannot be saved"). At the same time, if you make a fake payment service and make sure that it is called using the correct JS types, then your test is aimed at internals that are not related to the functionality of the application and which are likely to change frequently.
Otherwise. Any code refactoring involves finding and updating all fake objects in the code. Tests from an assistant friend turn into a burden.
Code examples
An example of antipattern. Fake objects are for guts.
it("When a valid product is about to be deleted, ensure data access DAL was called once, with the right product and right config", async () => { //Assume we already added a product const dataAccessMock = sinon.mock(DAL); //hmmm BAD: testing the internals is actually our main goal here, not just a side-effect dataAccessMock.expects("deleteProduct").once().withArgs(DBConfig, theProductWeJustAdded, true, false); new ProductService().deletePrice(theProductWeJustAdded); mock.verify(); });
How to do it right. Spies are designed to test requirements, but there is a side effect - they inevitably affect the insides.
it("When a valid product is about to be deleted, ensure an email is sent", async () => { //Assume we already added here a product const spy = sinon.spy(Emailer.prototype, "sendEmail"); new ProductService().deletePrice(theProductWeJustAdded); //hmmm OK: we deal with internals? Yes, but as a side effect of testing the requirements (sending an email) });
1.6 Do not use “foo”, use realistic input
What to do. Often production bugs occur with very specific and surprising input data. The more realistic the data during testing, the more likely to catch bugs on time. To generate pseudo-real data simulating the variety and type of production data, use special libraries, for example, Faker . Such libraries can generate realistic phone numbers, user nicknames, bank cards, company names, even the text “lorem ipsum”. You can create tests (on top of unit tests, and not instead of them) that randomize fake data to fit a module into a test, or even import real data from a production environment. Want to go even further? Read the next chapter (about property-based testing).
Otherwise. Your development testing will look successful using synthetic inputs like “Foo”, and production data may crash when a hacker @3e2ddsf . ##' 1 fdsfds . fds432 AAAA
tricky line like @3e2ddsf . ##' 1 fdsfds . fds432 AAAA
@3e2ddsf . ##' 1 fdsfds . fds432 AAAA
@3e2ddsf . ##' 1 fdsfds . fds432 AAAA
.
Code examples
An example of antipattern. A test suite that runs successfully due to the use of unrealistic data.
const addProduct = (name, price) =>{ const productNameRegexNoSpace = /^\S*$/;//no white-space allowed if(!productNameRegexNoSpace.test(name)) return false;//this path never reached due to dull input //some logic here return true; }; test("Wrong: When adding new product with valid properties, get successful confirmation", async () => { //The string "Foo" which is used in all tests never triggers a false result const addProductResult = addProduct("Foo", 5); expect(addProductResult).to.be.true; //Positive-false: the operation succeeded because we never tried with long //product name including spaces });
How to do it right. Randomize realistic input.
it("Better: When adding new valid product, get successful confirmation", async () => { const addProductResult = addProduct(faker.commerce.productName(), faker.random.number()); //Generated random input: {'Sleek Cotton Computer', 85481} expect(addProductResult).to.be.true; //Test failed, the random input triggered some path we never planned for. //We discovered a bug early! });
1.7 Use property-based testing to validate multiple input combinations
What to do. Usually for each test we select several samples of input data. Even if the input format is similar to real data (see the chapter “Do not use“ foo ”), we cover only a few combinations of input data (method ('', true, 1)
, method ("string" , false" , 0)
). But in operation, an API that is called with five parameters can be called with thousands of different combinations, one of which can lead to a process crash ( fuzzing ). What if you could write one test that automatically sends 1000 combinations of input data and fixing, at what combinations the code does not return the correct answer? The same thing we do with m todike test based on the properties:. by sending all possible combinations of input data into the test unit we increase the chance of a bug detection, for example, we have a method addNewProduct(id, name, isDiscount)
. Supporting his library will call this method with many combinations (, , )
, for example, (1, "iPhone", false)
, (2, "Galaxy", true)
etc. You can test based on properties using your favorite test runner (Mocha, Jest etc.) and libraries like js-verify or testcheck (it has much better documentation). You can also try the fast-check library , which offers additional features and is actively accompanied by the author.
Otherwise. You are thoughtlessly choosing input data for the test, which covers only well-functioning code execution paths. Unfortunately, this reduces the effectiveness of testing as a means of detecting errors.
Code examples
How to do it right. Test numerous combinations with the mocha-testcheck.
require('mocha-testcheck').install(); const {expect} = require('chai'); const faker = require('faker'); describe('Product service', () => { describe('Adding new', () => { //this will run 100 times with different random properties check.it('Add new product with random yet valid properties, always successful', gen.int, gen.string, (id, name) => { expect(addNewProduct(id, name).status).to.equal('approved'); }); }) });
1.8 Use only short and inline shots if necessary.
What to do. When you need to test based on snapshots , use only short snapshots without all the extra (for example, in 3-7 lines), including them as part of the test ( Inline Snapshot ), and not as external files. Following this recommendation will keep your tests self-evident and more reliable.
On the other hand, the “classic snapshot” guides and tools provoke us to store large files (for example, markup of component rendering or JSON API results) on external media and compare the results with the saved version every time the test is run. It can, say, implicitly associate our test with 1000 lines containing 3000 values that the author of the test never saw about which he had not expected. Why is that bad? Because there are 1000 reasons for the test to fail. Even one line can invalidate a snapshot, and this can happen often. How much After each space, comment, or minor change in CSS or HTML. In addition, the name of the test will not tell you about the failure, because it only checks that 1000 lines have not changed, and also encourages the author of the test to take as long as desired a long document that he could not analyze and verify. All of these are symptoms of an obscure and hasty test that does not have a clear task and is trying to achieve too much.
It is worth noting that there are several situations in which it is acceptable to use long and external images, for example, when confirming the scheme, and not the data (extracting values and focus on the fields), or when the received documents rarely change.
Otherwise. UI tests fail. The code looks fine, ideal pixels are displayed on the screen, so what happens? Your testing with snapshots just revealed the difference between the original document and the just received one - one space character was added to the markup ...
Code examples
An example of antipattern. Associating a test with some unknown 2000 lines of code.
it('TestJavaScript.com is renderd correctly', () => { //Arrange //Act const receivedPage = renderer .create( <DisplayPage page = "http://www.testjavascript.com" > Test JavaScript < /DisplayPage>) .toJSON(); //Assert expect(receivedPage).toMatchSnapshot(); //We now implicitly maintain a 2000 lines long document //every additional line break or comment - will break this test });
How to do it right. Expectations are visible and in the spotlight.
it('When visiting TestJavaScript.com home page, a menu is displayed', () => { //Arrange //Act receivedPage tree = renderer .create( <DisplayPage page = "http://www.testjavascript.com" > Test JavaScript < /DisplayPage>) .toJSON(); //Assert const menu = receivedPage.content.menu; expect(menu).toMatchInlineSnapshot(` <ul> <li>Home</li> <li> About </li> <li> Contact </li> </ul> `); });
1.9 Avoid global test benches and initial data, add data to each test separately
What to do. According to the golden rule (chapter 0), each test should add and work within its own set of rows in the database to avoid bindings, and it was easier for users to understand the test. In reality, testers often violate this rule, before running tests filling the database with initial data (seeds) ( also called a “test bench” ) in order to increase productivity.And although performance is really an important task, it can decrease (see the chapter “Testing components”), however, the complexity of the tests is much more harmful and it is it that should most often manage our decisions. Almost every test case should explicitly add the necessary records to the database and work only with them. If performance is critical, then as a compromise, you can only fill in the initial data with tests that do not change information (for example, queries).
Otherwise. Several tests failed, deployment aborted, now the team will spend precious time, do we have a bug? Let's look, damn it, it seems that two tests changed the same initial data.
Code examples
An example of antipattern. Tests are not independent and use some kind of global hook to get global data from the database.
before(() => { //adding sites and admins data to our DB. Where is the data? outside. At some external json or migration framework await DB.AddSeedDataFromJson('seed.json'); }); it("When updating site name, get successful confirmation", async () => { //I know that site name "portal" exists - I saw it in the seed files const siteToUpdate = await SiteService.getSiteByName("Portal"); const updateNameResult = await SiteService.changeName(siteToUpdate, "newName"); expect(updateNameResult).to.be(true); }); it("When querying by site name, get the right site", async () => { //I know that site name "portal" exists - I saw it in the seed files const siteToCheck = await SiteService.getSiteByName("Portal"); expect(siteToCheck.name).to.be.equal("Portal"); //Failure! The previous test change the name :[ });
How to do it right. You can stay within the test, each test only works with its own data.
it("When updating site name, get successful confirmation", async () => { //test is adding a fresh new records and acting on the records only const siteUnderTest = await SiteService.addSite({ name: "siteForUpdateTest" }); const updateNameResult = await SiteService.changeName(siteUnderTest, "newName"); expect(updateNameResult).to.be(true); });
1.10 Do not catch errors, but expect them
. , - , try-catch-finally , . ( ), .
Chai: expect(method).to.throw
( Jest: expect(method).toThrow()
). , , . , , .
. (, CI-) , .
An example of antipattern. A long test script that tries to catch an error using try-catch.
/it("When no product name, it throws error 400", async() => { let errorWeExceptFor = null; try { const result = await addNewProduct({name:'nest'});} catch (error) { expect(error.code).to.equal('InvalidInput'); errorWeExceptFor = error; } expect(errorWeExceptFor).not.to.be.null; //if this assertion fails, the tests results/reports will only show //that some value is null, there won't be a word about a missing Exception });
. , , , QA .
it.only("When no product name, it throws error 400", async() => { expect(addNewProduct)).to.eventually.throw(AppError).with.property('code', "InvalidInput"); });
//this test is fast (no DB) and we're tagging it correspondingly //now the user/CI can run it frequently describe('Order service', function() { describe('Add new order #cold-test #sanity', function() { it('Scenario - no currency was supplied. Expectation - Use the default currency #sanity', function() { //code logic here }); }); });
before(() => { //adding sites and admins data to our DB. Where is the data? outside. At some external json or migration framework await DB.AddSeedDataFromJson('seed.json'); }); it("When updating site name, get successful confirmation", async () => { //I know that site name "portal" exists - I saw it in the seed files const siteToUpdate = await SiteService.getSiteByName("Portal"); const updateNameResult = await SiteService.changeName(siteToUpdate, "newName"); expect(updateNameResult).to.be(true); }); it("When querying by site name, get the right site", async () => { //I know that site name "portal" exists - I saw it in the seed files const siteToCheck = await SiteService.getSiteByName("Portal"); expect(siteToCheck.name).to.be.equal("Portal"); //Failure! The previous test change the name :[ });
. , .
it("When updating site name, get successful confirmation", async () => { //test is adding a fresh new records and acting on the records only const siteUnderTest = await SiteService.addSite({ name: "siteForUpdateTest" }); const updateNameResult = await SiteService.changeName(siteUnderTest, "newName"); expect(updateNameResult).to.be(true); });
3:
3.1. UI
. , , , . , , ( HTML CSS) . , (, , , ), , , .
. 10 , 500 (100 = 1 ) - - .
. .
test('When users-list is flagged to show only VIP, should display only VIP members', () => { // Arrange const allUsers = [ { id: 1, name: 'Yoni Goldberg', vip: false }, { id: 2, name: 'John Doe', vip: true } ]; // Act const { getAllByTestId } = render(<UsersList users={allUsers} showOnlyVIP={true}/>); // Assert - Extract the data from the UI first const allRenderedUsers = getAllByTestId('user').map(uiElement => uiElement.textContent); const allRealVIPUsers = allUsers.filter((user) => user.vip).map((user) => user.name); expect(allRenderedUsers).toEqual(allRealVIPUsers); //compare data with data, no UI here });
. UI .
test('When flagging to show only VIP, should display only VIP members', () => { // Arrange const allUsers = [ {id: 1, name: 'Yoni Goldberg', vip: false }, {id: 2, name: 'John Doe', vip: true } ]; // Act const { getAllByTestId } = render(<UsersList users={allUsers} showOnlyVIP={true}/>); // Assert - Mix UI & data in assertion expect(getAllByTestId('user')).toEqual('[<li data-testid="user">John Doe</li>]'); });
// the markup code (part of React component) <b> <Badge pill className="fixed_badge" variant="dark"> <span data-testid="errorsLabel">{value}</span> <!-- note the attribute data-testid --> </Badge> </b> // this example is using react-testing-library test('Whenever no data is passed to metric, show 0 as default', () => { // Arrange const metricValue = undefined; // Act const { getByTestId } = render(<dashboardMetric value={undefined}/>); expect(getByTestId('errorsLabel')).text()).toBe("0"); });
. CSS-.
<!-- the markup code (part of React component) --> <spanid="metric"className="d-flex-column">{value}</span> <!-- what if the designer changes the classs? --> // this exammple is using enzyme test('Whenever no data is passed, error metric shows zero', () => { // ... expect(wrapper.find("[className='d-flex-column']").text()).toBe("0"); });
3.3
. , , . , , . , — - , (. « » ). (, ) , .
, : , . ( ) . , .
. , . ?
. .
classCalendarextendsReact.Component{ static defaultProps = {showFilters: false} render() { return( <div> A filters panel with a button to hide/show filters <FiltersPanel showFilter={showFilters} title='Choose Filters'/> </div> ) } } //Examples use React & Enzyme test('Realistic approach: When clicked to show filters, filters are displayed', () => { // Arrange const wrapper = mount(<Calendar showFilters={false} />) // Actwrapper.find('button').simulate('click'); // Assertexpect(wrapper.text().includes('Choose Filter')); // Thisishowtheuserwillapproachthiselement: bytext })
. .
test('Shallow/mocked approach: When clicked to show filters, filters are displayed', () => { // Arrange const wrapper = shallow(<Calendar showFilters={false} title='Choose Filter'/>) // Act wrapper.find('filtersPanel').instance().showFilters(); // Tap into the internals, bypass the UI and invoke a method. White-box approach // Assert expect(wrapper.find('Filter').props()).toEqual({title: 'Choose Filter'}); // what if we change the prop name or don't pass anything relevant? })
// using Cypress cy.get('#show-products').click()// navigate cy.wait('@products')// wait for route to appear // this line will get executed only when the route is ready
. , DOM- (@testing-library/dom).
// @testing-library/dom test('movie title appears', async () => { // element is initially not present... // wait for appearance await wait(() => { expect(getByText('the lion king')).toBeInTheDocument() }) // wait for appearance and return the element const movie = await waitForElement(() => getByText('the lion king')) })
. .
test('movie title appears', async () => { // element is initially not present... // custom wait logic (caution: simplistic, no timeout) const interval = setInterval(() => { const found = getByText('the lion king'); if(found){ clearInterval(interval); expect(getByText('the lion king')).toBeInTheDocument(); } }, 100); // wait for appearance and return the element const movie = await waitForElement(() => getByText('the lion king')) })
it('When doing smoke testing over all page, should load them all successfully', () => { // exemplified using Cypress but can be implemented easily // using any E2E suite cy.visit('https://mysite.com/home'); cy.contains('Home'); cy.contains('https://mysite.com/Login'); cy.contains('Login'); cy.contains('https://mysite.com/About'); cy.contains('About'); })
// this is how one can describe tests using cucumber: plain language that allows anyone to understand and collaborate Feature: Twitter new tweet I want to tweet something in Twitter @focus Scenario: Tweeting from the home page Given I open Twitter home Given I click on "New tweet" button Given I type "Hello followers!" in the textbox Given I click on "Submit" button Then I see message "Tweet saved"
# Add as many domains as necessary. Key will act as a label domains: english: "http://www.mysite.com" # Type screen widths below, here are a couple of examples screen_widths: - 600 - 768 - 1024 - 1280 # Type page URL paths below, here are a couple of examples paths: about: path: /about selector: '.about' subscribe: selector: '.subscribe' path: /subscribe
functionaddNewOrder(newOrder) { logger.log(`Adding new order ${newOrder}`); DB.save(newOrder); Mailer.sendMail(newOrder.assignee, `A new order was places ${newOrder}`); return {approved: true}; } it("Test addNewOrder, don't use such test names", () => { addNewOrder({asignee: "John@mailer.com",price: 120}); });//Triggers 100% code coverage, but it doesn't check anything
describe("Too short description", () => { const userToken = userService.getDefaultToken() // *error:no-setup-in-describe, use hooks (sparingly) instead it("Some description", () => {});//* error: valid-test-description. Must include the word "Should" + at least 5 words }); it.skip("Test name", () => {// *error:no-skipped-tests, error:error:no-global-tests. Put tests only under describe or suite expect("somevalue"); // error:no-assert }); it("Test name", () => {*//error:no-identical-title. Assign unique titles to tests });
//install license-checker in your CI environment or also locally npm install -g license-checker //ask it to scan all licenses and fail with exit code other than 0 if it found unauthorized license. The CI system should catch this failure and stop the build license-checker --summary --failOn BSD