Software development entails a lot of work like building new features, fixing bugs, infrastructure maintenance, keeping track of dependencies, phasing deprecated solutions out, etc. All of this works even without considering product, people, or operations.
A slice of the work mentioned above constantly requires input from a human brain. Software is fundamentally 1s and 0s, but the end goal is to provide value to humans. Without any breakthrough in artificial intelligence, figuring out features that can be implemented and suit human needs programmatically remains a dream.
Either way, there are a lot of tedious tasks like running tests, publishing releases, deploying features, keeping a repository clean. This task follows the same pattern every time, and they are not less important than others.
We don't need any artificial or otherwise intelligence for these tasks every single time. We need to do it once to create some job and have that same job run based on some triggers.
- Initial project setup
- Linting and formatting
- Design system
- Unit testing
- End-to-end testing
- Workflow automation
- Code releases
The following will be based on a front-end project with Vite + React + TypeScript.
Initial project setup
- Compatibility Note: Vite requires Node.js version >=12.2.0.
- Tutorial Node.js version: 16.13.0
- Tutorial NPM version: 8.1.0
This will scaffold a Vite project with React and TypeScript pre-configured and ready for us to work on. The project folder structure should look something like the following:
Default project structure generated by the Vite CLI (Command Line Interface)
You can run the development server via the npm run dev script:
Running this command will start a local Vite development server on http://localhost:3000 and by accessing that link you should see something like the following:
At this point, we have a front-end environment to create a web app with React + TypeScript. 🎉
To let everyone know which Node.js and NPM version the project runs properly, we can configure them via the engines property in the package.json file:
Snippet of the package.json file with the engines configuration added
Unless the user has set the engine-strict config flag, this field is advisory only and will just produce warnings when your package is installed as a dependency.
To automatically use the correct version of Node.js and NPM, we can use a tool called Node Version Manager (aka nvm). Installation is straightforward and you can read more about it on the documentation.
After successful installation, we can integrate it on your shell of choice to automatically run nvm use when we change to the project directory on the project. This command will read the Node.js version inside a .nvmrc file in the current directory if present. To set up this shell integration, you can follow this documentation. For this demo project, we created a .nvmrc file with the following content:
.nvmrc file content
Linting and formatting
Having code that's well written is great; otherwise, the development will get harder and harder over time.
To keep code consistency across the project, we can configure some tools to enforce specific rules for everyone and run any code changes against them to validate if all constraints are being followed.
There are two kinds of tools to help us this these tasks:
- linters for ensuring that best practices are used in the code and nothing odd is getting through the development;
- formatters for standardizing our code style and making it readable.
For setting up ESLint we need to install it and add a configuration file with all rules we want.
Command to install ESLint package as a project dependency
Output of running the ESLint init command
The above commands will install ESLint on the project and a configuration file .eslintrc.json as well.
We just installed packages to lint code following certain rules. Let's also install an ESLint plugin to enforce the rules of hooks on React code:
After installing it, we need to update the ESLint configuration file and add the plugin to the extends array:
Ignoring unnecessary files is a good way to keep ESLint performance. Create a .eslintignore file at the root of the project. This file will tell ESLint which files and folders it should never lint. Add the following lines to the file:
The TypeScript ESLint parser doesn't run type checking on the code by default. One of the main reasons to use TypeScript is being a strongly typed programming language. Ignoring the biggest feature of TypeScript isn't what we want, so let's enable type checking on the ESLint config:
With ESLint configured we can now run it on all the TypeScript files of the project, but before that, let's add a new NPM script to run it:
At this point, we have a linter and a formatter for our TypeScript code. A front-end project will also have styles files via CSS files, that’s where Stylelint comes into play to help us keep our styles consistent across the project. Let's start by installing all required tools:
After installing Stylelint, we need to create a .stylelintrc.json configuration file at the root of our project with the following content:
Just like we did with ESLint, let's ignore unnecessary files to keep Stylelint performance. Create a .stylelintignore file at the root of the project. This file will tell Stylelint which files and folders it should never lint. Add the following lines to the file:
With Stylelint configured, we can now run it on all the CSS files of the project. But, before that, let's add a new NPM script to run Stylelint:
The lint:styles script will run Stylelint on all CSS files inside the src folder.
For a deeper code editor integration, for example, when using Visual Studio Code, we can use a couple of extensions to help us by giving feedback as we develop our code.
For the tools we just installed, we can install the following extensions:
After installing those 2 extensions, you will get warnings/errors from both ESLint and Stylelint inside your code as you type. To have the best developer experience possible, having all fixable issues been fixed on each save is a must.
To enable this, we can add a little bit of configuration to tell VSCode to do some extra tasks when saving file changes. Let's create a settings.json file inside a .vscode folder with the following content:
For enforcing a consistent code style, we will use the code formatter called Prettier alongside its config/plugin for ESLint integration.
NPM script to install all Prettier related packages
With these packages installed, let's update the .eslintrc.json file by adding "plugin:prettier/recommended" to the extends array and "prettier/prettier": "error" to the rules object.
To keep the same code style previously generated, a couple of changes need to be made related to Prettier. Prettier configuration can be done via a couple of options, and for this tutorial we are gonna use a .prettierrc file:
.prettierrc configuration file content
One of the packages is a set of Prettier rules for Stylelint, so let's update the .stylelintrc.json configuration file as well:
When working on a front-end project, having an isolated environment where we can develop our UI components in isolation is a must nowadays. That's where tools like Storybook come into play. Storybook allows us to develop entire UIs without needing to start up a complex dev stack, force certain data into your database, or navigate around your application.
Let's get started by installing Storybook on our project:
Setup Storybook via the CLI with Vite as its builder
The command above will make the following changes to our demo project:
- Install the required dependencies;
- Set up the necessary scripts to run and build Storybook;
- Add the default Storybook configuration;
- Add some boilerplate stories to get you started.
Our design system environment is ready to go. Two scripts are now available for us to use Storybook — one for running the development environment npm run storybook, and another to build our design system npm run build-storybook.
Let’s remove the boilerplate stories and create a new story for our project App.tsx component. Create a App.stories.tsx file inside the src folder with the following content:
Now, we will run the Storybook development server to see this new story:
After the server starts, it should automatically open a new browser window on the http://localhost:6006 url. It will look something like this:
As you can probably tell, the rendered App component looks slightly different. The font displayed is not the same as on the Vite development server that we used before.
This happens because we are not using the base styles in the Storybook environment. Let's fix that by importing the src/index.css file on all stories. To share configuration with all stories, we can use the .storybook/preview.js file:
With our base styles being added to all stories, let's check what the rendered App component looks like once more:
The component is looking as we expected now! 👏
You can read more about how to write stories on this documentation.
Unit tests are crucial to ensuring our software is reliable. We must not skip writing them and also not skip running them.
For this tutorial, demo project, we will need to configure Jest a little bit to use TypeScript.
Let's start by installing Jest on the project:
Install Jest package as a project dependency
After installing it as a dependency, we will create an NPM script on package.json to run Jest.
After adding the script, let's run it with npm test and see something like the following:
As you can see, Jest did run and threw an error saying that it didn't find any tests. That's correct as our project is empty. For testing purposes, we’ll create a simple test file on the src folder called demo.test.js:
Now that we have 1 unit test, we can run Jest again and see the result:
All test suites run successfully.
Although everything is working testing-wise, let's add ESLint rules specific for Jest-related files. These rules will come from the ESLint Jest plugin.
Now, we need to update the .eslint.json file to enable this plugin:
The Jest documentation says that it supports TypeScript via Babel, but its Typescript support purely transpilation, Jest will not type-check your tests as they are run.
We definitely want type-checking, so we can use ts-jest instead. Let’s set up Jest with ts-jest:
Now we configure Jest to use the installed preset via a jest.config.js configuration file:
Rename the test file from demo.test.js to demo.test.ts and run the tests again:
All test suites run successfully with TypeScript files now.
Our demo test file is a very simple one. Since our project has React components, we should be able to test them. To test React components, we can use a library called React Testing Library which provides light utility functions on react-dom and react-dom/test-utils, in a way that encourages better testing practices.
Let’s update the jest.config.js file to have the correct test environment:
With this configuration, we are ready to start testing React components. Create a App.test.tsx file inside the src folder with the following content:
Now we have a test for the App component, so let's run our test suite with npm test.
When running the test suite with our current configuration, we get an error message like this:
From the output above, we can see that Jest is telling us that it couldn't understand the content that is trying to import from the logo.svg file. We need to configure Jest to mock static files to avoid this error. We can do that by updating the jest.config.js file to something like:
There is one new package mentioned in the code snippet above. Let's install it as well:
Moreover, there is a new file called fileMock.js being mentioned that we need to create inside a new folder called __mocks__ with the following content:
Now by running the tests suite again, we will have a successful result:
Let's start by installing Cypress itself:
This will install all Cypress required dependencies and scaffold a couple of example files for us to get started.
We can open Cypress now to see if it runs correctly. To open Cypress, let's add a new NPM script called cypress:open to our package.json file:
With this new script, we can simply run npm run cypress:open to open Cypress.
After opening, we will have a new cypress folder and a cypress.json file as well. You should have a new window open that looks something like this:
Now, let’s set up ESLint + TypeScript + Testing Library for Cypress. Starting by installing all required packages:
With those installed, we can start with the ESLint setup by creating a new .eslintrc.json inside the cypress folder with the following content:
Next, to write our e2e tests in TypeScript, we will create a tsconfig.json file inside the cypress folder as well with:
Let's update our root .eslintrc.json file to exclude the cypress folder:
Let's update our jest.config.js file to only match test files in the src folder:
Before we set up the Testing Library for Cypress, let's delete all example .spec.js files inside the cypress and rename all remaining files to have .ts extensions instead of .js. The cypress folder structure should look something like this:
Cypress folder file structure
Last but not least, let’s set up Testing Library by adding this line to your project's cypress/support/commands.ts file:
You can now use all of DOM Testing Library's findBy, findAllBy, queryBy, and queryAllBy commands off the global cy object.
With that in mind, let's create a new app.spec.ts file inside the cypress/integration folder:
The above integration test will load our Vite project home page, click on the counter button and assess that the counter value is indeed incremented.
You should get the following by opening Cypress and running the app.spec.ts integration test:
You may have noticed that we are loading the page by providing a full URL to the visit Cypress method. We can abstract the base URL into an environment variable. To do that, a small update to the cypress.json file will do it:
After this change, we can remove the base URL value from our integration test:
Continuous Delivery (CD)
They provide a preview feature that automatically generates links for every commit, making it easy to get feedback on the final result.
For example, when reviewing a pull request, we can check the preview link and see the changes on an isolated deploy environment.
This preview functionality is either enabled by default or easily enabled on the platform project settings.
For this tutorial, we are going to use Cloudflare Pages. First, we need to create a new Cloudflare Pages project:
Now, we have a continuous delivery setup for our React project. We also have our design system (Storybook) that would be great to deploy as well.
To deploy Storybook projects, there is one platform called Chromatic that was designed specifically to automate workflows with Storybook.
Let's start by adding a new project to our Chromatic account:
We will choose our project from GitHub and then the following instructions will appear:
It's telling us to install the chromatic as a project dependency:
Now, instead of running npx chromatic --project-token=***, we will create a new GitHub action file to deploy Storybook automatically. Create a new chromatic.yml file inside the .github/workflows folder with the following content mentioned on the documentation:
GitHub Action .github/workflows/chromatic.yml
Before pushing the new workflow file, please add the CHROMATIC_PROJECT_TOKEN secret to your repository settings. More information on how to set up this can be found here.
After successfully deploying our Storybook, the Chromatic page will update to something like:
And that's it for our design system's continuous deployment workflow.
Continuous integration (CI)
We have set up a couple of tools on the previous sections with all scripts that we can run manually to lint, format, and test our code.
We can set up a continuous integration workflow to automatically run all of our tools when we push code to our source code management (SCM) provider.
There are multiple SCM providers, and for this tutorial, we will use GitHub.
To set up our continuous integration workflow, we can use GitHub Actions to run our linting, formatting, and test scripts.
Let's start by creating a new workflow main.yml file inside the .github/workflows folder:
GitHub Action .github/workflows/main.yml
The code above will trigger a GitHub Actions workflow on pull requests.
First, it does a checkout of the branch code to get the latest code changes.
Next, a Node.js setup takes place by reading the corresponding version from the .nvmrc file. A cache is also configured to cache our dependencies between workflow runs.
Thereafter, it installs all project dependencies via the npm install command.
With our setup ready, the next step in the workflow is to lint our JS/TS code by running npm run lint:scripts
After ESLint runs successfully, we lint our CSS code by running npm run lint:styles
At this point, if all steps run successfully, we know that our code is in a good shape. Next, let's run our unit tests script:
The code above will run Jest with the environment variable CI set to true in order to exit right away if at least one test fails. This way the workflow can end as early as possible.
Only one more script runs on this workflow. We can now run our E2E tests with Cypress. For an easier setup, we will use another GitHub Action from Cypress to run our tests.
The code above will use the cypress-io/github-action GitHub Action that does a couple of things for us, so we don't have to.
We pass a couple of configuration variables to the action like the build and start script, the wait-on variable that tells the action to wait for an URL to become available, a browser variable that we set to chrome. Still, you can use other supported browsers as well. And last but not least, we set the variable headless to true to open the browser in headless mode.
When merging new commits into the default branch, a common task is tagging our code with a git tag and then generating release notes of the new code changes merged.
Tagging and generating the release notes manually can be tedious and prone to user error.This task can be automated with a tool called semantic-release that automates the whole package release workflow, including determining the next version number, generating the release notes, and publishing the package.
By default, to determine the next version number, semantic-release analyses the commit messages that follow the Angular commit message conventions.
Now let's configure commitlint by creating a .commitlintrc.json file:
Commit code with a bad message
As you can see above, the commit-msg hook exited with an error. There are two problems with our commit message: subject may not be empty and type may not be empty.
Let's try to commit again with a message that follows the Angular commit messages conventions:
Now we were able to commit successfully. 🎉
With the linter for commit messages in place, we can now set up semantic-release:
Install semantic-release package
Let's create a .releaserc.json configuration file with the following content:
semantic-release configuration file
The last step is to create a GitHub action to run semantic-release automatically when new commits are pushed to the main branch. Create a release.yml file inside the .github/workflows folder with the following content:
GitHub Action to run semantic-release on the main branch
Now, let's push the latest changes and see the GitHub action releasing a new version. After the release workflow finished running, we should have our first release created automatically:
Hopefully, these examples inspire you to set up the right workflow for your work, spending a bit of time once to reap the rewards of saved time indefinitely.