DEV Community

Cover image for Tetris: Choosing the tools and setting up the project
Derk-Jan Karrenbeld
Derk-Jan Karrenbeld

Posted on

Tetris: Choosing the tools and setting up the project

๐Ÿ”™ This is the second instalment of a series on building a Tetris clone using React. If you've missed the first, find it here.

Today we'll take a step towards starting the project. I'll discuss various options and choices which you might encounter when you're bootstrapping your own projects. It's important to talk about these - especially since a lot of tutorials and guides completely skip over the why - and you'll notice that not everything is crystal clear of has a single way to move forward.

๐ŸŽฎ In this series I'll show you all the steps to build a Tetris clone, abiding by the Tetris Guideline, the current specification that The Tetris Company enforces for making all new (2001 and later) Tetris game products alike in form.

๐Ÿ›‘ Tetris is licensed which means that if you intend to take this series of articles to build your own arcade puzzler, make sure to abide by the law, if you intend to commercially release it. Even if you provide a clone for free, you could still get a cease and desist. This reddit thread is pretty comprehensive how to go about this. Additionally, this Ars Technica article talks in-depth about how courts judge gaming clones using Tetris and the alleged clone Mino as an example.

๐Ÿ“š This series is purely meant as an educational, non-commercial resource. We'll only be using the fandom wiki as a resource and only use the name Tetris to indicate the type of game and not the actual company, game(s) or brand.

Photo showing various machines in the Arcade

Table of Contents

The Game Engine

Since this series has a game as its deliverable, it can be wise to pick a game engine. As taken from the WikiPedia article, a game engine is a software-development environment designed for people to build video games. There is an entire list of game engines, which isn't complete, and choosing which one to use for your game is such an endeavour that many have entire articles or video about it. In my opinion, if you're building a game, from scratch, and you have the time, potential and choice, you only need to ask yourself the following question:

  1. Do I ever want to go multiplayer? Pick Unreal Engine.
  2. Do I want to build a First-Person Shooter (single- or multiplayer)? Pick Unreal Engine.
  3. Otherwise, pick Unity.

I'm basing this on the hours and hours of GDC talks, and job listings! There are many more interesting engines, but if you need something which other people will trust and be able to work with, quickly, you probably need to pick one of these two.

If you're a one-person shop, and building for the web, there is a collection of javascript game engines, including well-known options such as GameMaker Studio (2).

However, since this series is building a Tetris clone using react, that is exactly what I'll use. Ask yourself: is React the right tool for the job? Meh, probably not (because there are better tools. Just because you can make something work, doesn't mean it was the right choice). Does that matter? It depends on the people you work with and the willingness of working around abstractions and challenges.

The Toolchain

Since react is supposed to be used for this project, it is likely that this project will be built as a JavaScript application. JavaScript projects (and libraries) tend to have a (sub)set of tools, which I refer to as the "toolchain".

Package management

A package manager has its function it the name: it manages packages. JavaScript modules, as listed in your package manifest (the collection of packages that the project depends on, for example listing an URL or a name, and version or version-range) are dependencies for your project. The current popular ones include Yarn and NPM.

You might ask: "But don't I always need a package manager?" The answer to that is a short no. You can also opt to:

  • Including all your dependencies locally, for example by vendoring (the act of storing dependencies locally to the project) them. This means you always have a working copy, without the need for the interwebs.
  • Use a runtime that doesn't use packages in the traditional sense, such as deno, but also using unpkg, which makes your HTML file the dependency manifest and manager in one.
  • Use system packages such as .debian packages, and manage dependencies using a system tool such as make and a Makefile. This technically still uses a package manager, but not in the same way as the Yarn or npm options.

๐Ÿ’Ž I'm choosing Yarn with the package manifest inside package.json. I think there are many valid reasons to pick any of the options above. I've used most of them myself, including now defunct tools such as Bower. It often doesn't matter, and the decision should be made in line with company policies or project team alignment.

Bundler

A bundler in the JavaScript eco-system is not to be confused with the package manager bundler from the Ruby eco-system. In the JavaScript eco-system, it usually takes care of the following set of feature, or a sub-set thereof:

  • collecting all the assets in your project (JS, HTML, files, images, CSS)
  • stripping out unused assets (think tree-shaking, dead code/import elimination)
  • applying transformations (transpilation e.g. Babel, post processing e.g. PostCSS)
  • outputting code bundles (chunks, code splitting, cache-friendly output)
  • error logging (more friendly)
  • hot module replacement (automatically updating modules / assets during development)

Some of the tools I've used in the past and still use are Webpack, Parcel, Rollup, microbundle, Browserify and Brunch. The same can be achieved using a task runner such as Grunt or using Gulp, but in my experience, those tend to get out of hand fast.

The choice here, again, doesn't really matter. I think they all have their strengths and weaknesses, and you should pick whichever you feel comfortable with. If you foresee you'll need to customise a lot, some will be favourable over others. If your team knows one of them better than the others, that will probably be favourable. In general: a great bundler is replaceable.

๐Ÿ’Ž I'm not choosing anything yet! I'll let the rest of the toolchain dictate what bundler I want to go with. If done right, it won't be that costly to replace the bundler later. Perhaps I won't need any.

Compiler

Technically, babel is mostly a transpiler, as it compiles code to the same level of abstraction (think JavaScript ESNext to JavaScript ES3). A compiler generally compiles code to a lower level of abstraction (think Java to JVM / ByteCode, TypeScript to JavaScript). That said, Babel lists itself as a compiler, which it also is as it can remove TypeScript token from TypeScript code, yielding valid JavaScript

๐Ÿ’Ž Since I want some type-safety, and I'm better at using TypeScript (which does come with a compiler which also transpiles) than Flow (which is technically a static type checker and not a compiler or transpiler), I'm choosing TypeScript for compilation to type-check and then use babel to actually compile and then transpile the files to JavaScript.

Linting and Styleguides

According to WikiPedia, Lint, or a linter, is a tool that analyses source code to flag programming errors, bugs, stylistic errors, and suspicious constructs. Since I'll be using TypeScript, I'm at least looking for a code-linter.

๐Ÿ’Ž TSLint has been deprecated, as Microsoft has rolled out an amazing toolset which enables ESLint to support Typescript, called typescript-eslint.

I also think that it's good practise to pick a coding style guide (e.g. do you use semicolons or not) and apply that to the project. Towards this goal, I'll use prettier.

Testing libraries

Alright, this one is also not groundbreaking. Whilst there are a lot of options here, such as mocha, jasmine, tape, or one of my favourites AVA, I'll use jest. I personally think it has all the great features I love from AVA, but because Facebook uses it internally, there is quite a bit of React tooling that hooks perfectly into jest.

Base Library

There are currently multiple options when you want to develop in "react":

๐Ÿ’Ž Without going to deep into detail, since this series is focused on writing a game using react, that's what I'll use; if there is a lot of ask by the end of the series, I'll write one about how to move from react to preact.

Bootstrap

If you've read the react docs, you might know that there are several "toolchains" out there. They are mostly wrappers providing a single Command-Line Interface (CLI) and come bundled with all the dependencies (tools), as listed above in the various categories. The React team primarily recommends a few solutions, and I tend to agree with them:

  • If youโ€™re learning React or creating a new single-page app, use Create React App.
  • If youโ€™re building a server-rendered website with Node.js, try Next.js.
  • If youโ€™re building a static content-oriented website, try Gatsby.
  • If youโ€™re building a component library or integrating with an existing codebase, try Neutrino, nwb, Parcel or Razzle.

I'd like to throw react-static in the mix as well as an alternative to next.js and gatsby, which allows you to build super fast static content sites, hydrated with a react-app, without the requirement of using GraphQL or a server.

This is a very important decision, because if you choose to use a bootstrapped project with one of the toolchains above, you'll be somewhat tied to their technologies, choice of configuration and general ideas. Most of the tools allow you to eject (to stop using the built-in defaults), but you'll still have to to a lot of work to move away.

๐Ÿ’Ž The project (Tetris clone) is probably completely feasible without a complete bootstrapped toolchain. That's why I've chosen not to use one of the bootstrapping toolchains. Often I run into meh behaviour when I need to add something that it currently doesn't support correctly out of the box, when I try to upgrade dependencies or anything similar. If I end up needing it, I can always add it later!

Initialisation of the project

# Create the directory for this new project
mkdir tetreact

# Move into that directory
cd tetreact

# Install dependencies
yarn add react react-dom

# Install development dependencies (explanation below)
yarn add typescript core-js@3 eslint eslint-config-prettier eslint-plugin-import -D
yarn add eslint-plugin-jsx-a11y eslint-plugin-react eslint-plugin-react-hooks -D
yarn add jest babel-jest prettier @babel/cli @babel/core @babel/preset-env -D
yarn add @babel/preset-react @babel/preset-typescript @typescript-eslint/eslint-plugin -D
yarn add @typescript-eslint/parser @testing-library/react @testing-library/jest-dom -D
yarn add @types/jest @types/react @types/react-dom -D

# Make this a git repository
git init
Enter fullscreen mode Exit fullscreen mode

Here is why the following packages are being installed:

  • react and react-dom are runtime packages for react,
  • typescript: used to type-check the ts and tsx files,
  • core-js: a library that polyfills features. There is an older, version (@2) and a newer version (@3).
  • eslint: the core package for the linter,
  • eslint-config-prettier: turns off conflicting, stylistic rules that are handled by prettier,
  • eslint-plugin-import: adds rules and linting of import and export statements,
  • eslint-plugin-jsx-a11y: adds accessibility rules on JSX elements,
  • eslint-plugin-react: adds React specific linting rules,
  • eslint-plugin-react-hooks: adds React Hooks specific linting rules,
  • jest: the testing framework,
  • babel-jest: makes it possible to run the test code through babel,
  • @babel/cli: allows me to run babel as a standalone command from the command line,
  • @babel/core: the core package for Babel,
  • @babel/preset-env: preset to determine which transformations need to be applied on the code, based on a list of browsers,
  • @babel/preset-react: preset that allows transpilation of JSX and ensures React's functional component's property displayName is correctly set,
  • @babel/preset-typescript: allows stripping TypeScript type tokens from files, leaving behind valid JavaScript,
  • @typescript-eslint/eslint-plugin: adds a lot of rules for linting TypeScript,
  • @typescript-eslint/parser: allows eslint to use the TypeScript ESLint parser (which knows about type tokens),
  • @testing-library/react: adds officially recommended testing library, for react,
  • @testing-library/jest-dom: adds special matchers for jest and the DOM,
  • @types/*: type definitions

You might think: "jee, that's a lot of dependencies", and yep, it's quite a few. However, when using something like create-react-app, you are installing the same if not more dependencies, as these are dependencies of the react-scripts project you'll be depending on. I've spent quite some time on getting this list to where it is, but feel free to make your own changes and/or additions.

Normally I would add these dependencies as I go, but I already did all the steps listed below, so I collected all the dependencies and listed them in two single commands for you to copy and paste.

Setting up typescript correctly

The following is to setup typescript. The dependencies added for this are:

  • typescript: provides the tsc typescript compiler and allows you to have a project version, different from a version e.g. bundled with your IDE or text editor.

Run the tsc --init command in order to create the tsconfig.json with the default settings.

yarn tsc --init
Enter fullscreen mode Exit fullscreen mode

Now I need to make a few changes, all of which are explained below:

-  // "incremental": true,
+  "incremental": true
-  // "target": "es5",
+  "target": "esnext",
-  // "jsx": "preserve",
+  "jsx": "preserve",
-  // "noEmit": true,
+  "noEmit": true,
-  // "isolatedModules": true,
+  "isolatedModules": true,
-  // "moduleResolution": "node",
+  "moduleResolution": "node",
-  // "allowSyntheticDefaultImports": true,
+  "allowSyntheticDefaultImports": true,
Enter fullscreen mode Exit fullscreen mode

Remember, the goal is to have tsc type-check the codebase. That means there doesn't need to be an output, hence noEmit. Furthermore, it doesn't need to spend time transpiling to an older JavaScript, because babel will take care of that, which means it can have an esnext target. For the same reason, jsx is set to preserve and not react. Babel will take care of that. Then there are a few options that make interoptability with other packages easier. Finally, isolatedModules is required for the TypeScript over Babel functionality to work correctly.

Additionally, package.json needs to get the "scripts" key with a command that runs the type-checking.

+  "scripts": {
+    "lint:types": "yarn tsc"
+  }
Enter fullscreen mode Exit fullscreen mode

Running yarn lint:types should yield the following error:

error TS18003: No inputs were found in config file 'path/to/tetreact/tsconfig.json'. Specified
'include' paths were '["**/*"]' and 'exclude' paths were '[]'.


Found 1 error.
Enter fullscreen mode Exit fullscreen mode

This is the correct error. There is nothing to compile! Let's add that:

mkdir src
touch src/App.tsx
Enter fullscreen mode Exit fullscreen mode

Running yarn lint:types should yield the following errors:

node_modules/@types/babel__template/index.d.ts:16:28 - error TS2583: Cannot find name 'Set'. Do
you need to change your target library? Try changing the `lib` compiler option to es2015 or later.

16     placeholderWhitelist?: Set<string>;
                              ~~~

node_modules/@types/react/index.d.ts:377:23 - error TS2583: Cannot find name 'Set'. Do you need
to change your target library? Try changing the `lib` compiler option to es2015 or later.

377         interactions: Set<SchedulerInteraction>,
                          ~~~

src/App.tsx:1:1 - error TS1208: All files must be modules when the '--isolatedModules' flag is
provided.

1
Enter fullscreen mode Exit fullscreen mode

Let's start at the first two. These give an explicit option to fix the error.

-  // "lib": [],
+  "lib": ["dom", "es2015"],
Enter fullscreen mode Exit fullscreen mode

This is very similar to setting the correct env in your .eslintrc configuration file: I need to tell TypeScript that I'm in a browser environment (dom) and that it should be able to access those constructs that have been introduced in es2015.

The final error is because of the --isolatedModules flag. When running the compiler with this flag/option, each file expects to be its own, free-standing module. A file is only a module if it imports or exports something. The reason for this flag isn't apparent: It's listed on the documentation of @babel/plugin-transform-typescript as one of the caveats of "compiling" TypeScript using Babel. I have advanced knowledge here, but it would become clear in the next step.

I update the src/App.tsx file:

import React from 'react'

export function App(): JSX.Element {
  return <div>Hello world</div>
}
Enter fullscreen mode Exit fullscreen mode

Finally, tsc does not complain.

Setting up babel correctly

Next up is making sure that babel "compiles" the TypeScript code to JavaScript, applies transformations and hooks into the various plugins that I've installed.

  • core-js@3: a library that polyfills features. There is an older, version (@2) and a newer version (@3); it uses used by @babel/preset-env in conjunction with a browerlist configuration,
  • @babel/cli: allows me to run babel as a standalone command from the command line,
  • @babel/core: the core package for Babel,
  • @babel/preset-env: preset to determine which transformations need to be applied on the code, based on a list of browsers,
  • @babel/preset-react: preset that allows transpilation of JSX and ensures React's functional component's property displayName is correctly set,
  • @babel/preset-typescript: allows stripping TypeScript type tokens from files, leaving behind valid JavaScript.

Babel currently, at moment of writing, does not have an --init command, but setting it up is not very complicated, albeit it takes some effort to get all the presets and plugins correctly listed. Since this is a project, per the babel documentation, the best way for this project is to create a JSON configuration, called .babelrc.

touch .babelrc
Enter fullscreen mode Exit fullscreen mode

The contents are as follows, which I collected by taking the documentation of the three @babel/preset-* plugins and applying them:

{
  "presets": [
    [
      "@babel/preset-env", {
        "targets": {
          "node": "current"
        },
        "useBuiltIns": "usage",
        "corejs": { "version": 3 }
      }
    ],
    "@babel/preset-typescript",
    "@babel/preset-react"
  ],
  "ignore": [
    "node_modules",
    "dist"
  ]
}
Enter fullscreen mode Exit fullscreen mode

It's also a good idea to explicitly define the browserlists key/configuration, even though since I'm building a cross-env cross-browser game, the setting can stay on defaults. In order to do that, and in order to be abel to call babel using @babel/cli, in package.json, I added the following:

   {
     "scripts": {
+      "build": "yarn babel src --out-dir dist --extensions \".ts,.tsx\"",
+      "watch": "yarn build --watch",
       "lint:types": "yarn tsc"
     },
     "dependencies": {

  ...

       "typescript": "^3.5.3"
     },
+    "browserslist": [
+      "defaults"
+    ]
   }
Enter fullscreen mode Exit fullscreen mode

If you want a different target, make sure to follow the Browserlist best practices. You can also use a configuration file; pick whichever you like.

Let's see if this works!

$ yarn build
yarn run v1.16.0
warning package.json: No license field
$ babel src --out-dir dist --extensions ".ts,.tsx"
Successfully compiled 1 file with Babel.
Done in 1.67s.
Enter fullscreen mode Exit fullscreen mode

In dist I can now find App.js, which does not have any type information. It should look something like this:

"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.App = App;

var _react = _interopRequireDefault(require("react"));

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

function App() {
  return _react.default.createElement("div", null, "Hello World!");
}
Enter fullscreen mode Exit fullscreen mode

A few things to notice:

  • It added "use strict";
  • It is using the interopRequireDefault to require react's default export
  • It transpiled JSX to use _react.default.createElement

These three things would only happen if Babel is configured correctly.

Setting up eslint correctly

Next step is making sure that the TypeScript code can be linted!

  • eslint: the core package for the linter,
  • eslint-config-prettier: turns off conflicting, stylistic rules that are handled by prettier,
  • eslint-plugin-import: adds rules and linting of import and export statements,
  • eslint-plugin-jsx-a11y: adds accessibility rules on JSX elements,
  • eslint-plugin-react: adds React specific linting rules,
  • eslint-plugin-react-hooks: adds React Hooks specific linting rules,
  • @typescript-eslint/eslint-plugin: adds a lot of rules for linting TypeScript,
  • @typescript-eslint/parser: allows eslint to use the TypeScript ESLint parser (which knows about type tokens).

The eslint core package comes with a CLI tool to initialise (and run) eslint:

$ yarn eslint --init

? How would you like to use ESLint? To check syntax and find problems
? What type of modules does your project use? JavaScript modules (import/export)
? Which framework does your project use? React
? Where does your code run? Browser
? What format do you want your config file to be in? JSON

Successfully created .eslintrc.json file in path/to/tetreact
Enter fullscreen mode Exit fullscreen mode

Depending on your configuration, and depending if you call yarn eslint (execute eslint from the local node_modules) or plain eslint (which might call the "globally" installed eslint), the following message may appear:

The config that you've selected requires the following dependencies:

eslint-plugin-react@latest

? Would you like to install them now with npm? No
Enter fullscreen mode Exit fullscreen mode

I choose "No" because on one hand, it's already installed under devDependencies and on the other hand, it will try to use npm to install it if I say "yes" (at moment of writing), which is something I don't want (as I am using yarn).

As for the options: I personally like the .json file, because it restricts me from solving something using JavaScript, which makes the barrier to do something "hackly" a bit higher. I basically guard myself from trying to do something that is not supported out of the box. Your mileage may vary, but I like to use my dependencies with standard configuration, because it makes it easier to search for solutions and ask for support!

๐Ÿ›‘ If you run into an error that looks like this:

ESLint couldn't find the plugin "eslint-plugin-react". This can happen for a
couple different reasons:
...

Even though it is installed locally, remove eslint from the global packages:

yarn global remove eslint

If you're using an IDE with eslint integration set-up, chances are that both App.js (in the dist folder) รกnd App.tsx (in the src folder) light up with errors. This is to be expected. It doesn't automagically configure .eslintrc.json with all the plugins from your devDependencies.

In order to get all the configurtion in, I edit the generated .eslintrc.json.

  • First, I mark the configuration as the root configuration. This prohibits any eslint configuration somewhere up the tree to apply rules to this project.
  • Next, I update the parserOptions and tell it to use the @typescript-eslint/parser parser. My article on writing a TypeScript code Analyzer goes into a bit more detail on what the different @typescript-eslint/* packages are and do.
  • Finally, there are all the extends. These take preset configurations that I want to apply to this configuration. The @typescript-eslint/* and prettier/* modules have documentation that explains in what order these should be placed.
   {
+    "root": true,
+    "parser": "@typescript-eslint/parser",
     "parserOptions": {
+      "project": "./tsconfig.json",
+      "ecmaFeatures": {
+        "jsx": true
+      },
       "ecmaVersion": 2018,
       "sourceType": "module"
     },
     "env": {
       "browser": true,
       "es6": true
     },
-    "extends": "eslint:recommended"
+    "extends": [
+      "eslint:recommended",
+      "plugin:@typescript-eslint/eslint-recommended",
+      "plugin:@typescript-eslint/recommended"
+      "plugin:react/recommended",
+      "prettier",
+      "prettier/@typescript-eslint",
+      "prettier/babel",
+      "prettier/react"
+    ],
     "globals": {
       "Atomics": "readonly",
       "SharedArrayBuffer": "readonly"
     },
     "plugins": [
-      "react",
+      "@typescript-eslint",
+      "react-hooks",
     ],
     "rules": {
     },
+    "settings": {
+      "react": {
+        "version": "detect"
+      }
+    }
   }
Enter fullscreen mode Exit fullscreen mode

The rules are currently still empty, I'll get to that. First, let's test the configuration!

Testing the eslint configuration

I change src/App.tsx:

+  function Header() {
+    return <h1>Hello World!</h1>
+  }

   export function App(): JSX.Element {
-    return <div>Hello World!</div>
+    return <Header />
   }
Enter fullscreen mode Exit fullscreen mode

And add a new scripts entry:

   "scripts" {
     "build": "yarn babel src --out-dir dist --extensions \".ts,.tsx\"",
      "watch": "yarn build --watch",
+     "lint": "yarn eslint src/**/*",
      "lint:types": "yarn tsc"
   },
Enter fullscreen mode Exit fullscreen mode

Now I run it!

yarn lint

$ eslint src/**/*

path/to/tetreact/src/App.tsx
  3:1  warning  Missing return type on function  @typescript-eslint/explicit-function-return-type

โœ– 1 problem (0 errors, 1 warning)

Done in 4.01s.
Enter fullscreen mode Exit fullscreen mode

Woopdiedo. A warning from the @typescript-eslint plugin! This is exactly what I expect to see, so I can now move on fine-tuning the "rules".

Fine-tuning the rules

Normally I fine-tune the "rules" as I develop a library or a project, or I use a set of rules that is pre-determined by a project lead. In the exercism/javascript-analyzer respository, I've added a document about the rules and why I chose them to be like this. The results are as listed below, which include the two react-hooks rules at the bottom.

{
  "rules": {
    "@typescript-eslint/explicit-function-return-type": [
      "warn", {
        "allowExpressions": false,
        "allowTypedFunctionExpressions": true,
        "allowHigherOrderFunctions": true
      }
    ],
    "@typescript-eslint/explicit-member-accessibility": [
      "warn", {
        "accessibility": "no-public",
        "overrides": {
          "accessors": "explicit",
          "constructors": "no-public",
          "methods": "explicit",
          "properties": "explicit",
          "parameterProperties": "off"
        }
      }
    ],
    "@typescript-eslint/indent": ["error", 2],
    "@typescript-eslint/no-non-null-assertion": "off",
    "@typescript-eslint/no-parameter-properties": [
      "warn", {
        "allows": [
          "private", "protected", "public",
          "private readonly", "protected readonly", "public readonly"
        ]
      }
    ],
    "@typescript-eslint/no-unused-vars": "off",
    "@typescript-eslint/no-use-before-define": [
      "error", {
        "functions": false,
        "typedefs": false
      }
    ],
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}
Enter fullscreen mode Exit fullscreen mode

As I write more code, this ruleset may change, but for now this should suffice.

Setting up jest correctly

Next up is making sure the code is testable.

I personally don't like to co-locate my test files next to my source files, but rather put all the tests in a separate directory. However this isn't better or preferred, just different. You can do whichever you like. If you co-locate the tests, make sure that your tests end with .test.ts or .test.tsx, and if you don't, the default folder is __tests__. You can change these in the, soon to be, generated jest.config.js.

The dependencies that matter are:

  • jest: the testing framework,
  • babel-jest: makes it possible to run the test code through babel,
  • @testing-library/react: adds officially recommended testing library, for react,
  • @testing-library/jest-dom: adds special matchers for jest and the DOM,

Just like some of the other tools, jest comes with a CLI and an option that allows you to generate the configuration file.

$ yarn jest --init

โˆš Would you like to use Jest when running "test" script in "package.json"? ... yes
โˆš Choose the test environment that will be used for testing ยป jsdom (browser-like)
โˆš Do you want Jest to add coverage reports? ... yes
โˆš Automatically clear mock calls and instances between every test? ... no
Enter fullscreen mode Exit fullscreen mode

This adds the test script to "scripts" in package.json and adds a jest.config.js file with defaults to the root directory.
The contents of the configuration file are all set correctly (given the answers as listed above), with the important ones being (you can go in and confirm):

  • coverageDirectory should be set to "coverage", because I want coverage reports,
  • testEnvironment should not be set or set to "jest-environment-jsdom", because I don't want to have to run in a browser.

The babel-jest package is automagically supported, out-of-the-box, without needing to set-up anything else. Since Babel is already configured correctly to "compile" the source code, and the test code has the same properties, no steps need to be taken in order to make the tests be "compiled" as well.

Then I want to integrate with the @testing-library/react library, which provides a cleanup script that makes sure the React application state and environment is reset (cleaned-up) after each test. Instead of including this in every test, it can be setup via the jest.config.js file:

-  // setupFilesAfterEnv: []
+  setupFilesAfterEnv: [
+    '@testing-library/react/cleanup-after-each'
+  ],
Enter fullscreen mode Exit fullscreen mode

I use the default folder name for my tests:

mkdir __tests__
Enter fullscreen mode Exit fullscreen mode

And now I create a smoke test __tests__/App.tsx with the following:

import React from 'react'
import { render } from '@testing-library/react'
import { App } from '../src/App';

it('App renders heading', () => {
  const {queryByText} = render(
    <App />,
  );

  expect(queryByText(/Hi/)).toBeTruthy();
});
Enter fullscreen mode Exit fullscreen mode

Finally I run the tests using the "scripts" command that was added by yarn jest --init:

yarn test

$ jest
 FAIL  __tests__/App.tsx
  ร— App renders heading (29ms)

  โ— App renders heading

    expect(received).toBeTruthy()

    Received: null

      14 |   );
      15 |
    > 16 |   expect(queryByText(/Hi/)).toBeTruthy();
         |                             ^
      17 | });
      18 |

      at Object.toBeTruthy (__tests__/App.tsx:16:29)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        4.361s
Ran all test suites.
Enter fullscreen mode Exit fullscreen mode

Ah. I'm rendering Hello World, and not Hi. So now I change the regular expression to test for Hello World instead, and run the tests again:

$ jest
 PASS  __tests__/App.tsx
  โˆš App renders heading (21ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        4.184s
Ran all test suites.
Done in 6.10s.
Enter fullscreen mode Exit fullscreen mode

Enabling jest-dom extensions

You might have noticed that there is another @testing-library dependency. I want to use the '@testing-library/jest-dom/extend-expect' visibility check toBeVisible, instead of only testing if it exists via toBeTruthy. I order to integrate with that package, I make the following change to the jest.config.js:

   setupFilesAfterEnv: [
     '@testing-library/react/cleanup-after-each',
+    '@testing-library/jest-dom/extend-expect',
   ],
Enter fullscreen mode Exit fullscreen mode

This change makes the extension (new matchers, including .toBeVisible) available to all the tests.

I update the test to use these:

   import React from 'react'
   import { render } from '@testing-library/react'
   import { App } from '../src/App'

   it('App renders heading', () => {
     const { container, queryByText } = render(
       <App />,
     );

-    expect(queryByText(/Hello World/)).toBeTruthy()
+    expect(queryByText(/Hello World/)).toBeVisible()
   }
Enter fullscreen mode Exit fullscreen mode

Running the tests works, but my IDE gives an error on the toBeVisible matcher. This is because TypeScript doesn't quite know that the expect matchers have been extended. It's not good at inferring new types from dynamically executed code. Since there is no cross-file information between the jest configuration and this test, I can't expect that to be magically picked up. Fortunately, there are various ways to solve this, for example, but not limited to:

  • Add import '@testing-library/jest-dom/extend-expect' to each test file. This extends the expect() Matchers to include those provided by the library,
  • Make sure typescript knows this is always included (which is true, given the jest.config.js changes).

In order to get the "always included" experience, I add a new file declarations.d.ts and add a triple-slash directive. I generally stay clear of these directives, and even have an eslint rule to disallow them, but in my experience, tooling is best when you run into something like this issue and use them. This might not be true if you follow this post some time in the future. You can do whatever works, perhaps an import suffices:

touch __tests__/declarations.d.ts
Enter fullscreen mode Exit fullscreen mode
/* eslint-disable @typescript-eslint/no-triple-slash-reference */
/// <reference types="@testing-library/jest-dom/extend-expect" />
Enter fullscreen mode Exit fullscreen mode

What this does is tell TypeScript that for the current directory subtree (__tests__), it should always add the package' types as defined by the directive. I can now also see that the error in __tests__/App.tsx has been resolved and that it recognises .toBeVisible.

Getting a coverage report

There are no new dependencies required for a coverage report as jest comes bundled with built-in coverage.

In order to test if the coverage is working correctly, I first change the App.tsx src file to include a branch:

import React from 'react'

export interface AppProps {
  headingText?: string
}

export function App({ headingText }: AppProps): JSX.Element | null {
  if (headingText === undefined) {
    return null
  }

  return <h1>{headingText}</h1>
}
Enter fullscreen mode Exit fullscreen mode

Now, the app renders null unless a headingText is given. I also have to change the test to pass in "Hello World" as the heading text, or the test will
fail:

-  <App />
+  <App headingText="Hello World" />,
Enter fullscreen mode Exit fullscreen mode

I run the test suite with coverage enabled:

yarn test --coverage
Enter fullscreen mode Exit fullscreen mode

This runs the tests and they are passing; it also outputs the following table summary:

----------|----------|----------|----------|----------|-------------------|
File      |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files |    66.67 |       50 |      100 |    66.67 |                   |
 App.tsx  |    66.67 |       50 |      100 |    66.67 |                 9 |
----------|----------|----------|----------|----------|-------------------|
Enter fullscreen mode Exit fullscreen mode

Line 9 is inside a conditional branch (for when headerText === undefined):

    return null
Enter fullscreen mode Exit fullscreen mode

This can be tested by explicitly adding a test.

it('App renders nothing without headingText', () => {
  const { container } = render(
    <App />,
  )

  expect(container.firstChild).toBeNull()
})
Enter fullscreen mode Exit fullscreen mode

I generally don't like to test that things are not there, because often you have to make a few assumptions that are fragile at best (and therefore break easily), but just to test if jest has been set-up correctly, this is fine, since I'll throw away these lines later:

$ jest --coverage
 PASS  __tests__/App.tsx
  โˆš App renders heading (46ms)
  โˆš App renders nothing without headingText (1ms)

----------|----------|----------|----------|----------|-------------------|
File      |  % Stmts | % Branch |  % Funcs |  % Lines | Uncovered Line #s |
----------|----------|----------|----------|----------|-------------------|
All files |      100 |      100 |      100 |      100 |                   |
 App.tsx  |      100 |      100 |      100 |      100 |                   |
----------|----------|----------|----------|----------|-------------------|
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        4.911s
Ran all test suites.
Done in 6.78s.
Enter fullscreen mode Exit fullscreen mode

Setting up prettier correctly

Finally, I can focus on setting up the (automatic) code formatter! I really like prettier for the simple reason that it removes the need of discussing a lot of style choices. I don't think it always or even often generates pretty code, but that's okay. As their library improves, so does the output, and it's trivial to re-format all the code once they do.

  • eslint-config-prettier: turns off style rules that are in conflict with prettier. You can see the various prettier/* lines in the eslint configuration above. This has already been set-up.
  • prettier: the core package, including the CLI tools to run prettier.

Prettier has already been added to the eslint configuration, so that part can be skipped.

The prettier CLI doesn't have an --init option at the moment of writing, so I create the configuration file manually:

touch .prettierrc.json
Enter fullscreen mode Exit fullscreen mode

I've chosen to loosly follow the StandardJS style, but it really doesn't matter. Pick a style and stick with it.

{
  "trailingComma": "es5",
  "tabWidth": 2,
  "semi": false,
  "singleQuote": true,
  "jsxSingleQuote": false
}
Enter fullscreen mode Exit fullscreen mode

I also want to be able to run these as a script, so I add the following three "scripts":

   "lint:types": "yarn tsc",
+  "lint:format": "yarn format --check",
+  "format": "yarn prettier \"{src,__{tests}__}/**/*.{ts,tsx}\"",
+  "format:fix": "yarn format --write",
   "test": "yarn jest"
Enter fullscreen mode Exit fullscreen mode

Automatically formatting

Since prettier has been added as plugin to eslint, it is already correctly integrated with eslint. However, you might want code to be formatted on save. The prettier documentation lists a lot of IDEs and allow you to turn on formatting on save.

In general, I'm not a fan of running prettier on commit, because it slows down my commits, occasionally breaks things and I think it shouldn't be a concern of the commit to format the code. That said, I do think it's a good idea to add a check in the continuous integration (CI) to test the format of the project.

Conclusion

And that's it! The project is now in a pretty good state to start writing code. Yes, it took quite a bit to get here and a lot of the configuration setup above is exactly why tools such as create-react-app or even the parcel bundler exist. Note that I haven't actually dealt with some of the things that parcel and webpack deal with, such as importing images or other file types; I don't think I'll need it, and therefore I didn't add that.

A few things are left to do:

  • Set-up CI,
  • Add the "name" and "license"` fields,
  • Add the servability i.e. add the HTML file that we can see in a browser.

Next time I will actually write some game code, and perhaps the things just listed, but for now, this is all I give you.

Photo of

Top comments (0)