How to Build a Webstore Using Modern Stack (Nest.js, GraphQL, Apollo) Part 2
Zebra: an open source webstore.
Join the DZone community and get the full member experience.
Join For FreeThis is the second article in this series. The first can be found here. Before moving on to build new functionality for the store, we still need to cover several topics that must be added in order to create a good product:
Descriptive and useful documentation.
Unit tests.
Integration tests.
That's of course not an ultimate list of missing things, but they are very crucial and I will focus on adding them in this section. As this functionality is missing in part one and has to be as part of the source code, I'll continue using the 0.1.x-maintenance branch of the main project.
You may also like: Nest.js Brings TypeScript to Node.js and Express.js, Part 1.
Documentation
For keeping the documentation a bit more fancy than the usual Markdown in Readme.md I decided to use some open source project that:
Can do the job pretty well.
Are easy to spin up.
Have minimum hassle in customization.
Support markdown files as a source for generating the content.
After some investigation, I found a nice project loved by thousands of developers (at the moment of creating this article, it has slightly more than 13,000 stars and is used across well known open source projects. It was enough to convince myself to stop my choice at https://docusaurus.io.
I have quite broad experience in software development, as it comes to almost 14 years. I can say that documentation is a weak point for developers; it's always done in the last moment and mostly forced to be done. And that's not about user-facing documentation on how to use a product or service, but how to:
Work with the code.
Configure the development environment.
Align with the best practices chosen exactly in that project.
Troubleshoot known issues.
And many more related exclusively to developers. So, why is creating and maintaining solid documentation so important?
First of all — the bus factor. If only one person has certain knowledge about a key part of a project and that person gets hit by a bus, the whole team be in jeopardy, as nobody knows how that part of the project works. In the best-case scenario, another team member will spend significant time to understand how it really works. In the worst, the code has to be rewritten or duplicated.
Second — Help your team be as efficient as you are. If you create a script/command that simplifies development, it most likely will be hidden from others, especially if it resides in a huge codebase that's already difficult to comb through. Creating documentation for the snippet would allow for other team members to make use of it in the same way you do.
Third — Keep onboarding easier, especially for junior-level developers. If you don't want to spend most of your time answering the same questions again and again, it can be a good motivator for you to invest your time once in creating documentation so that you can hand it to each new developer on day one and spend your time focusing on larger issues.
Having all these reasons in mind let's configure Docusaurus.
How I did this in the project:
I ran
yarn global add docusaurus-init
to install a project generation command.I executed it
docusaurus-init
and as a result created docs and website folders.- You can then run this with
yarn start
.
As you can see, it's super easy to run a default setup.
Let's have a look at the generated code. You'll find out that it's React, that's fantastic, isn't it? :) You can literally update everything that you can see on the site. As the beginning, I started by adding my content in the Docs section. For that, you need to create your *.md file in the Docs folder and have a special header inside.
---
id: frontend
title: Frontend
---
id
is used for linking it with layout, and title
will be visisble on side panel.
To make that item appear in sidebar, you have to update website/sidebars.json
and specify it in a proper structure, depending on where you want to see it. It will look something like this:
{
"docs": {
"Getting started": [
"introduction",
{
"type": "subcategory",
"label": "Technology stack",
"ids": [
"circle-ci",
"gulp",
"lerna",
"webpack",
"nest",
"typeorm",
"graphql",
"frontend",
"react-graphql"
]
}
]
}
}
So, as you can see, you can create subcategories too by creating an item, which has its own "type," "label," and "ids." These will contain the link to the article that will be included in that subcategory.
To control header links visible on the right side of the top panel, you need to check siteConfig.js
:
headerLinks: [
{doc: 'introduction', label: 'Docs'},
{doc: 'api', label: 'API'},
{page: 'help', label: 'Help'}
],
Note: Bear in mind that after changing the configuration which modifies the layout, you need to restart Docusaurus to apply the changes!
Unit Testing
As there is a lot of functionality to unit test, let's break it up into parts:
Nest.js Controller Unit Testing
To run tests on that level, you can find a configuration in the src/server/package.json
line.
...
"test:e2e": "jest --config ./test/jest-e2e.json"
...
You can run it with yarn test:e2e
in the server
folder.
If you are using any IDE tool, you need to provide that config file to jest command to be able to run it inside of it. For example, in IntellijIDEA it will look like this
Testing Nest.js controller
import * as request from 'supertest';
import {Test} from '@nestjs/testing';
import {AppModule} from '../src/app.module';
import {INestApplication} from '@nestjs/common';
describe('Status Controller', () => {
let app: INestApplication;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
await app.init();
});
it(`/GET ping`, () => {
return request(app.getHttpServer())
.get('/ping')
.expect(200)
.expect('pong');
});
afterAll(async () => {
await app.close();
});
});
In beforeAll
, we first initialize the entire Nest application with the modules we are interested in. Afterward, we can send HTTP calls to verify the output. You can pay attention that there are two expects (lines 21 and 22), which are testing different things. Supertest
is matching the result based on type. If it is an integer, it will check on the return HTTP status code, and if it is anything else, it will match the body.
After the test is finished, don't forget to release resources (lines 26-28).
Testing Nest.js GraphQL endpoint
Here, we'll have a look on the concept of testing any GraphQL endpoint. The initialization phase is absolutely the same as in the previous test and the body of test case is
it('Get all products', () => {
return request(app.getHttpServer())
.post('/graphql')
.send({
operationName: null,
variables: {},
query: GET_ALL_PRODUCTS.loc.source.body
})
.expect(200)
.expect({data: {products: []}});
});
As you can see, we can send requests just to the /graphql
endpoint with interested for us GQL query. Line seven expects a string for the query. To not manually type a string and not change all the time test code when you change a query in source code, it's better to point to a query constant.
As in our case GET_ALL_PRODUCTS
. And as it is not a query but GQL, we need to get a query in a string representation. By calling x.loc.source.body
, it will fetch what we need.
Integration Testing
For intergration testing, I decided to use Cypress. It's a quite popular testing framework, as it supposed to be easy to use and handy in debugging. I didn't have any experience with it before, as Cypress doesn't support Firefox or Internet Explorer and at my work, we have to support it. For that we are using Protractor, as I'm already familiar with the issues present in Protractor and how to handle them.
Before we dive in, that let's have a look at what was done to configure Cypress for this project.
First that's of course dependencies on it in package.json:
{
...
"@cypress/webpack-preprocessor": "4.1.0",
"cypress": "3.3.1",
...
}
Webpack preprocessor is needed for Cypress to run on its own version of Node.
Second is the configuration of Cypress by creating a cypress.json file in the root of the project:
{
"baseUrl": "http://localhost:6517",
"defaultCommandTimeout": 5000,
"fixturesFolder": "src/client/test/e2e/cypress/fixtures",
"integrationFolder": "src/client/test/e2e/cypress/specs",
"pluginsFile": "src/client/test/e2e/cypress/plugins",
"screenshotsFolder": "src/client/test/e2e/cypress/screenshots",
"videosFolder": "src/client/test/e2e/cypress/videos",
"viewportHeight": 768,
"viewportWidth": 1024
}
Here, we specify the URL on which your application is running, how long to wait for a command to execute, default folders, and the resolution of the screen on which tests will be running.
To run Cypress, I've defined two commands in package.json
{
...
"scripts": {
"cypress:open": "cypress open",
"cypress:run": "cypress run"
}
...
}
You need cypress:open if you want to open the Cypress panel and run tests manually from there.
On the top right side, you can click on "Run all specs," and you will see how these tests will be running in when they're live. Any issues will be shown on the left panel.
Cypress:run will run tests in a headless mode, and you can see the results in the console. That works faster and in case of any issues, you can check logs, screenshots on the step where the test failed, and a video. Folders to them were specified in cypress.json.
If nothing is running at your local computer, and you want to spin up the backend server, frontend server, and run e2e tests against it, you can execute gulp e2e
. That command is also used in CircleCI to run the same tests.
Now about the test itself.
Scenarios to test:
Add product.
Check that it is added to the store.
Delete product.
Check that the message "No records to display" is visible on the second form. (This is an indirect check that that item was deleted.)
What we have for that in a spec:
import {Application} from '../dsl/application';
import {AllProductsDsl} from '../dsl/all-products-dsl';
describe('My First Test', function() {
before(function() {
Application.open(); // opening the application on the default page
});
it('should be able to create a new product', function() {
AllProductsDsl.addProduct('Milk', '0.95', '1 liter');
AllProductsDsl.checkProductInStore('Milk', '0.95', '1 liter');
AllProductsDsl.removeProduct(1);
AllProductsDsl.checkThatNoProductsInStore();
});
});
To make spec human-readable, I moved all Cypress and DOM specific logic to a DSL class. It will help me in the future to:
Not duplicate the code.
Make scenarios more easily readable and maintainable.
Make composing more complicated logic easier by utilizing DSL in DSL.
Let's check what is inside AllProductsDsl
:
export class AllProductsDsl {
static addProduct(name, price, description) {
cy.get('.product-form input[name="name"]').type(name);
cy.get('.product-form input[name="price"]').type(price);
cy.get('.product-form input[name="description"]').type(description);
cy.get('.product-form button[type="submit"]').click();
}
static checkProductInStore(name, price, description) {
cy.get('.product-list tbody tr td:nth-child(1)').contains(name);
cy.get('.product-list tbody tr td:nth-child(2)').contains(price);
cy.get('.product-list tbody tr td:nth-child(3)').contains(description);
}
static removeProduct(lineNumber) {
cy.get(`.product-list tbody tr:nth-child(${lineNumber}) td:nth-child(4) button`).click();
}
static checkThatNoProductsInStore() {
cy.get('.product-list tbody tr td').contains('No records to display');
}
}
To make such CSS selectors easier, I added product-form
and product-list
CSS classes to forms. I don't like lines 10-12, as their columns are checked by order, not name; any change in the structure of Product might involve changes in that test if new columns are added between existing ones. There is no easy way to add custom CSS classes for columns, as the table doesn't provide it via configuration, so I left it as-is for now.
By the way, for creating selectors, I recommend using Chrome Dev Tools where you can open the application and test your selector.
$$
denotes that you are using CSS selector search. There are other options, like $ for jquery search, of $x for XPath. Pay attention that when you have a valid selector, Dev Tools will display under your query an existing HTML element or array of HTML elements; you can click on it, and Chrome will display that element in the whole application DOM structure and highlight that element in the application.
So back to the Cypress problem. If you will run the posted test in the article, it will fail. And it will fail on the last check. Why? Because after clicking on the "delete" icon, it can't find a specified HTML element with the text "No records to display."
Though documentation regarding cy.get
and cy.contains
says that it will try to find an element by selector during the specified time and then only blow off. But even when the element is already visible, the browser still can't catch it. So, what you can do in that case?
The worst anti-pattern solution is: cy.wait(500)
.Although this will create an immediate result, it's far from best-practice. First, you can delay your tests significantly with unnecessary waiting. Second, if it works on your computer, there is no guarantee that it will work on a slower model. This also depends on how your computer loaded other tasks.
So, having such delays will introduce flakiness into and slow down your tests. A better approach is to wait for some element before it will be displayed and only then check some condition.
One thing which crept into my mind is that having a chain of ci.get(...).contains(...)
has a problem that I found here. You can check it out if you're curious. The solution is to use ci.contains(selector, '...text...')
. With any tool, you need to know what you have to avoid and where there are problems.
Please leave your feedback in the comments section; I'd really appreciate hearing your opinion about the article and the code!
Related Articles
Opinions expressed by DZone contributors are their own.
Comments