(Updated: )

How to add Type Checking and Linting to your Playwright Project

Share on social

Playwright and TypeScript logo with red squiggle lines.
Table of contents

If you bet on end-to-end testing or even synthetic monitoring, there’s a high chance that you use Microsoft's Playwright. And if you have Playwright in your toolchain, you probably adopted TypeScript, too. It's an easy choice because of its rock-solid auto-completion and type safety. 

With this setup, you can enjoy the beautiful DX (developer experience) and safely refactor your ever-growing code base without worrying about runtime exceptions because of TypeScript's type checking, right? Wrong!

Here’s a quote from the Playwright docs:

Note that Playwright does not check the types and will run tests even if there are non-critical TypeScript compilation errors.

That’s right! When you run npx playwright test, Playwright takes your *.spec.ts files, transforms them to JavaScript, and runs them. There's no type checking.

See it yourself. Here’s a quick example spec.ts file.

// ⚠️ This example code includes errors!
// Don’t copy and paste it.
import { expect, test } from "@playwright/test";


test("test with a type error", async ({ page }) => {
  await page.goto("https://playwright.dev/");


  await expect(
    page.getByRole("heading", { name: "Installation" })
  ).toBeVisibles();
});

This test includes one obvious error, and if you run npx playwright test, you'll be greeted with the following.

Result of `npx playwright test` showing that there's a typo in one assertion.

Ouch! There's a misspelled toBeVisible() assertion, and it blows up the test. Shouldn't TypeScript prevent these situations? Yes, this is exactly what type checking is for, but let me repeat: Playwright doesn't check your types, and there's no included type safety.

But you see type errors when you write your end-to-end tests; how does this work?

If you're using a modern editor, TypeScript is usually baked into it and will run in the background for any opened .ts file. You write your tests, get some hints, enjoy the auto-completion, and will be greeted with the casual red squigglies when you mess up.

VS Code showing a type error in a Playwright test.

Your editor shows you TypeScript errors because it is so kind to do the heavy lifting for you. Playwright, on the other hand, isn't protecting you. It compiles your code and doesn't care if it's full of type mismatches or "undefined is not a function" errors. Playwright will still run your tests. There are no guardrails and no additional help. 

And this "missing feature" might be okay for smaller projects, but remember that type safety is invaluable for large and complex projects.

Imagine an advanced test suite with hundreds of tests, dozens of POMs (Page Object Models), and even more util functions: when you make a small change, you probably will only run some tests locally and just push the code to run all the tests in CI/CD. When you use TypeScript without type checking, you will learn about typos and broken refactorings only after your pipeline spins up a browser to run your tests. You'll waste time, and only the complexity of your project determines how much.

Let's remove this uncertainty, make a Playwright project type safe again, and add linting with typescript-eslint to avoid the most common Playwright mistakes, too.

If you prefer a video walkthrough, find it on YouTube.

Otherwise, let's go!

The final code is on GitHub if you're interested in the TypeScript and `typescript-eslint` setup described in this article.

Add TypeScript type checking to your Playwright project

After kicking off a new Playwright project with the npm init playwright@latest command, you'll discover that the bootstrapped project comes with very few dependencies. @playwright/test is the only one worth mentioning. And even though I like lean projects, I value developer safety more.

Let's go ahead and install TypeScript as a new devDependency.

# install `typescript` as devDependency
npm install --save-dev typescript

Your package.json should now look as follows.

{
  "name": "pwt-playwright-type-check-and-lint",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {},
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "devDependencies": {
    "@playwright/test": "^1.45.3",
    "@types/node": "^20.14.11",
    "typescript": "^5.5.4"
  }
}

With TypeScript installed, the tsc command is available in your project, and you can now run npx tsc --init to create a tsconfig.json.

{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

The created tsconfig.json includes a gazillion config options and comments about the different TypeScript features. We won't dive into the TypeScript rabbit hole in this post because we only want to catch some simple type errors. Keep things as they are; the default configuration is fine.

With your new TypeScript config file, you can start running type checks from the command line.

# run the type check, and if it succeeds, run Playwright
npx tsc --noEmit && npx playwright test

The tsc command will automatically pick up your existing config file. The --noEmit flag instructs the compiler not to compile the *.ts files into JavaScript (Playwright will still do that for you) but instead just execute type checking.

And now look at it!

TypeCheck error

With the tsc --noEmit command running right before playwright test, you just added type safety to your Playwright project. TypeScript did what it's good at (complaining) and informed us that we messed up. It even provided a suggestion of what we did wrong. Great!

Of course, you don't want to run these commands manually. Let's wrap and split them into npm scripts, making them easier to differentiate.

{
  "scripts": {
    "pretest": "tsc --noEmit",
    "test": "playwright test"
  }
}

Thanks to the pre* npm lifecycle script convention, you can run multiple scripts with a single command. When you run npm run test, the pretest script will run first, and only if it succeeds the test script will be executed. Type checking first, end-to-end testing with real browsers after. Cool!

Since adding type checking to a Playwright project wasn't so hard, should we stop here?

Heck no! Now that we have a pretest step let's also bring in TypeScript linting. It'll help us catch common Playwright errors before we run our test code. Trust me, an additional linting step will be worth it!

Add TypeScript linting to your Playwright project

Type checking helps to avoid obvious runtime exceptions, but what about user errors? What if you're using Playwright incorrectly, and your mistakes won't blow up your tests but only make them fail? 

TypeScript linting will move your Playwright tests to the next level, and you'll never squint your eyes while looking for these tough-to-spot Playwright bugs again.

The most common Playwright mistake — incorrect promise usage

You probably know that Playwright bets on JavaScript promises. The test runner and core library hide all the asynchronous magic from you, and thanks to async/await, test cases look like they're running synchronous operations.

But under the hood, there's a ton of asynchronous auto-waiting happening, and it's quite easy to miss an await or, on the other hand, await too much.

Let's look at another example. This time, it includes two mistakes I always see when talking to customers.

// ⚠️ This example code includes errors!
// Don’t copy and paste it.
test("test with incorrect promise handling", async ({ page }) => {
  await page.goto("https://playwright.dev/")


  // this `await` is unnecessary 
  const button = await page.getByRole("link", { name: "Get started" })
  // this `click()` needs to be awaited
  button.click()
})

The two most common Playwright mistakes are treating synchronous methods as asynchronous or treating asynchronous methods as synchronous.

// Playwright locators are synchronous and 
// they'll be evaluated when used with actions and web-first assertions
// -> they don't need an `await`

// correct
const button = page.getByRole('button');
// incorrect
const button = await page.getByRole('button');

// -----
 
// Actions (`click()`), web-first assertions (`expect().toBeVisible()`) 
// and methods like `test.step()` are asynchronous
// -> they need an `await`
const button = page.getByRole('button');
await button.click()

The gnarly thing about these mistakes is that they often lead to unpredictable behavior. Sometimes, they will fail your test right away, and sometimes, they might work because of some race conditions. 

Incorrect promise handling leads to flakiness and can easily cause an afternoon-long bug-hunting tour. 

Luckily, some linting can help out here.

Add typescript-eslint to your Playwright project

typescript-eslint is a popular TypeScript linter that is also recommended by the Playwright team. Could it help with these two errors? You bet!

Let's install some more dev dependencies.

# install the `typescript-eslint` dependencies
npm install --save-dev eslint @eslint/js @types/eslint__js typescript-eslint

typescript-eslint relies on ESLint, so we must install this one and add some additional types.

After installing these, we can follow the instructions to get started and create an eslint.config.mjs at the root of our project.

// @ts-check

import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
);

This configuration file sets the ESLint and typescript-eslint recommended configuration. You don't have to worry about these. But now you can start linting your TypeScript-based Playwright code with npx eslint tests/**. And you'll see…

`npx eslint tests/**` without results.

… nothing. Our mistakes don't show up when we run the linting yet. Why? Because we haven't turned on the "advanced TypeScript" linting.

typescript-eslint supports what is called "Linting with type information". This feature enables the linter to understand the underlying type information. If you enable it, ESLint will not only check formatting and syntax rules but understand and evaluate your code.

For linting with type information to work, you must define the TypeScript settings in your eslint.config.mjs file. Luckily, we have already created a tsconfig.js, so you can specify the languageOptions as shown below.

The most valuable rules for Playwright projects are no-floating-promises and await-thenable. Let's add these in.

// @ts-check

import eslint from "@eslint/js";
import tseslint from "typescript-eslint";

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
  {
    // define TS project config to enable "linting with type information"
    languageOptions: {
      parserOptions: {
        // reuse the existing `tsconfig.json`
        project: true,
        tsconfigRootDir: ".",
      },
    },
    // enable linting rules beneficial for Playwright projects
    rules: {
      "@typescript-eslint/no-floating-promises": "error",
      "@typescript-eslint/await-thenable": "error",
    },
  }
);

If you now run npx eslint tests/**, your new linter will catch the two most common Playwright mistakes.

typescript-eslint errors shown after linting.

Let's tidy up again and add the linting step to our pretest script to finish things.

{
  "scripts": {
    "pretest": "tsc --noEmit && eslint tests/**",
    "test": "playwright test"
  }
}

When you now run your Playwright tests with npm run test, your code will be type-checked and linted before a browser is opened. This approach will not only make your tests safer but also help you detect mistakes faster. And this means you can stop wasting all these unnecessary CI/CD minutes to discover typos!

Conclusion

But let's get real: Are all these project dependencies and config files worth it? 

It's okay not to have these safety measures when running a small end-to-end testing project. But when you start extending Playwright, write page object models, and run many tests, you should bet on all the safety you can get.

Relying on some squiggled red lines in your editor won't help you tame a complex testing setup. And if you're not convinced yet, you'll see the argument once you enter endless CI/CD testing loops because of a major code refactoring. Good luck with that one!

But if you want to adopt type checking and linting, you can find the example code on GitHub

And while you are at it, remember that end-to-end testing of preview deployments won't guarantee a working production environment. The only way to sleep well and be safe is to run your Playwright tests constantly and be alerted when something's off

But if you're here on the Checkly blog, you know that already. 😉

Share on social