Skip to content

Setting Up React Navigation in Expo Web: A Practical Guide

Introduction

We have come a long way from the days where we had to write different code for different platforms. Today, we can write code once and run it anywhere using technologies like React Native.

React Native is a framework that allows us to write native apps for Android, iOS, and the web, using JavaScript and React. This makes it more interesting for teams that want to cut time/development cost to look into it.

We recently launched our Expo-Zustand-Styled Components showcase app here at This Dot Labs. We also showcased how we can use expo, zustand, and styled-components to build a React Native app that can run on Android, iOS, and the web all in one codebase.

In this article, we will be looking at React Navigation, how to configure Deep Linking to navigate to different screens, and handling dynamic routes paths in our app. We'll do this by looking at the challenges we had to deal with while working on our showcase app, especially when running the app on the web.

Getting started

Before we dive in, we need to install all the needed dependencies. Let's make sure we have the expo cli installed. We can do this by running the following command:

  npm i expo-cli

We can now Initialize a new Expo app. We can do this by running the following command:

    npx create-expo-app rn-web-routing

Next, we need to install the navigation package, and since we are also building this app for the web, we need to install the needed dependencies.

    npx expo install @react-navigation/native @react-navigation/native-stack react-dom react-native-web @expo/webpack-config

You can also leverage our expo-zustand-styled-component starter kit, which offers all configurations to build for IOS, Android, and the web.

Setting up the navigation

Let's set up our navigation routes. We will use native-stack for our navigation. We can do this by creating a navigation folder in our src folder, and creating an index.ts file in it. This will contain our root navigator.

import { createNativeStackNavigator } from "@react-navigation/native-stack";
import React from "react";
// import screens

const Stack = createNativeStackNavigator();

const Routes = () => {
  return <Stack.Navigator>{/** screens stack */}</Stack.Navigator>;
};

export default Routes;

We can now import our Routes component in our App.tsx file and render it.

    import React from 'react';
    import Routes from './navigation';

    export default function App() {
        return (
        {/** ... other config eg provider */}
        <Routes />
        {/** ... other config eg provider  */}
        );
    }

Creating our screens

We can now create our screens. We will create a screens folder in our src folder and a Home.tsx file in it which will contain our home screen.

import React from "react";
import { View, Text } from "react-native";

const Home = () => {
  return (
    <View>
      <Text>This is Home</Text>
    </View>
  );
};

export default Home;

We can now import our Home component in our Routes component, and add it to our stack.

import { createNativeStackNavigator } from "@react-navigation/native-stack";
import React from "react";
import Home from "../screens/Home";

const Stack = createNativeStackNavigator();

const Routes = () => {
  return (
    <Stack.Navigator>
      <Stack.Screen name="Home" component={Home} options={{ title: "Home" }} />
    </Stack.Navigator>
  );
};

export default Routes;

We can now do the same for the other screens we want to add to our stack.

Deep linking

During the development of the expo showcase kit, we faced an issue on the web. When we navigate to a page, the URL in the browser still remains in the index. This is because we have not configured deep linking.

Deep Linking is a way to navigate to different screens in our app using a custom URL link. This is very useful when we want to have individual URLs for each of our pages. This is also useful when we want to share a link to a specific screen in our app, saving the user time and energy in locating a particular page themselves.

We can do this by creating a config file in our navigation folder with the following code.

import { NavigationContainer } from "@react-navigation/native";
import * as Linking from "expo-linking";

const linking = {
  prefixes: [Linking.createURL("/")], // this is the prefix for our app. Could be anything eg https://myapp.com
  config: {
    screens: {
      Home: "",
      //  ... other screens
    },
  },
};

export default linking;

And then we can import our linking config in our App.tsx file and pass it to the NavigationContainer.

    export default function App() {
        return (
        {/** ... other config eg provider */}
        <NavigationContainer linking={linking}>
            <Routes />
        </NavigationContainer>
        {/** ... other config eg provider  */}
        );
    }

We will come back to this config file later as we are going to deep dive into more complex routing, and how to handle it. Let's run our app now, and see how it works.

Running the app

Now, we can run our app. We can do this by running the following command:

    expo start

Press w to open it on a web browser, you should see something like this:

We can see we are in our index page which is the home page, we can now navigate to the eg Profile page by clicking the button on the home page. To have your preferred page url path, we need to update the linking config with the appropriate page URL. Otherwise, deep linking will use the screen name as the page URL.

Handling dynamic routes

Say you want to have a page that can be accessed by different users. You can do this by passing the user id as a parameter in the URL. This is called dynamic routing. We can do this by updating our linking config file with the following code.

import { NavigationContainer } from "@react-navigation/native";
import * as Linking from "expo-linking";

const linking = {
  prefixes: [Linking.createURL("/")],
  config: {
    screens: {
      Home: "",
      Profile: "profile/:id",
      //  ... other screens
    },
  },
};

export default linking;

This will allow us to access the Profile page by passing the user id as a parameter in the URL. We can now update our Profile page to get the user id from the URL and display it.

import React from "react";
import { View, Text } from "react-native";
import { useRoute } from "@react-navigation/native";

const Profile = () => {
  const route = useRoute();
  const { id } = route.params;
  return (
    <View>
      <Text>This is Profile</Text>
      <Text>User id: {id}</Text>
    </View>
  );
};

export default Profile;

During the development of our showcase app, we were faced with an issue. We needed to replicate a route /tree/main/a/b/c with a pattern as /tree/:branch/:path*, where the path is a/b/cjust like we have in other showcases.

import { NavigationContainer } from "@react-navigation/native";
import * as Linking from "expo-linking";

const linking = {
  prefixes: [Linking.createURL("/")],
  config: {
    screens: {
      Home: "",
      Profile: "profile/:id",
      Tree: "tree/:branch/:path*",
      //  ... other screens
    },
  },
};

export default linking;

This didn't work as expected. But on further investigation, we found out that we have to manually handle this route in the getStateFromPath of the linking configuration file, by updating the state and returning it.

import { getStateFromPath } from "react-native";
import { NavigationContainer } from "@react-navigation/native";
import * as Linking from "expo-linking";

const linking = {
  prefixes: [Linking.createURL("/")],
  config: {
    screens: {
      Home: "",
      Profile: "profile/:id",
      Tree: "tree/:branch/:path",
      //  ... other screens
    },
  },
  getStateFromPath(path, options) {
    let state = getStateFromPath(path, options);

    // If the state is undefined, it means that the path doesn't match any route in our config, and
    // we want to handle the path ourselves to the right screen, by updating the state and returning it.
    if (!state) {
      // check if route contains the main identifier of the screen we want to show
      const isTree = path.includes("tree");
      if (isTree) {
        const [, , branch, ...rest] = path.split("/");

        state = {
          routes: [
            {
              path,
              name: "Tree",
              params: {
                branch,
                path: rest.join("/"), // here, our path is the rest of the path after the branch
              },
            },
          ],
        };
      }
    }
    return state;
  },
};

export default linking;

With these configurations, we can now navigate throughout our app using the Link component from react-navigation/native.

import React from "react";
import { View, Text } from "react-native";
import { Link } from "@react-navigation/native";

const Home = () => {
  return (
    <View style={styles.container}>
      <Text>This is Home</Text>
      <View style={styles.button}>
        <Link to="profile">
          <Button title="Go to Profile" />
        </Link>
      </View>
      <View style={styles.button}>
        <Link to="tree/d1/d2/d3">
          <Button title="Go to Tree" />
        </Link>
      </View>
    </View>
  );
};

To make use of the path parameter in our Tree page:

import React from "react";
import { View, Text } from "react-native";

const Tree = ({ route }) => {
  const { branch, path } = route.params;
  return (
    <View>
      <Text>This is Tree</Text>
      <Text>Branch: {branch}</Text>
      <Text>Path: {path}</Text>
    </View>
  );
};

export default Tree;

Conclusion

In this article, we have seen how to set up our navigation in our expo app using react-navigation/native. We have also seen how to configure deep linking, which will help us to navigate through our app using the URL path. Next, we've learned how to handle dynamic routes, which will help us pass parameters in the URL path, and handle some special cases like the one we had with the Tree page.

If you have any questions or run into any trouble, feel free to join the discussions going on at starter.dev or on our Discord.

This Dot Labs is a development consultancy that is trusted by top industry companies, including Stripe, Xero, Wikimedia, Docusign, and Twilio. This Dot takes a hands-on approach by providing tailored development strategies to help you approach your most pressing challenges with clarity and confidence. Whether it's bridging the gap between business and technology or modernizing legacy systems, you’ll find a breadth of experience and knowledge you need. Check out how This Dot Labs can empower your tech journey.

You might also like

Using Cypress with Cucumber in a React Application cover image

Using Cypress with Cucumber in a React Application

Introduction We are going to be learning how to integrate Cypress with Cucumber in a React application. Cypress is the premier end-to-end testing platform to allow you to test user interactions, and end-to-end flows in your application. What is Cucumber? Cucumber creates a layer of abstraction that allows for better communication between business and technical folks, and facilitates the use of Behavior Driven Development (BDD). One of the additional benefits is that it essentially creates documentation, which stays up to date and is easy to share. Cucumber is built to support Behavior Drive Development (BDD). If you are unfamiliar with Behavior Driven Development, you can read Cucumber’s guide on it here: https://cucumber.io/docs/bdd/ Learn more about Cucumber in general here: https://cucumber.io/docs/guides/overview/ What is Gherkin? In the simplest terms, Gherkin is a set of grammar rules that structure plain text for Cucumber to understand and process. Learn more here: https://cucumber.io/docs/gherkin/ Setup Steps - Clone a React app to test against.__ Here is a link to a photo viewer and search app which we will be using: https://github.com/Yog9/SnapShot You will need to clone the app and get it running before we can move on to next steps. Below is a screenshot of the working app: - Add Cypress as a dev dependency within your application.__ Install Cypress `npm i cypress --save-dev`. Once you have completed this step, you should see `"cypress": "^10.11.0",` or something similar in the `devDependencies` section of your `package.json` file. - Open and run Cypress to install it using the binary.__ You do this by typing the command `npx cypress open` in your terminal. If it's your first time running this command within your application, Cypress will prompt you to install some example tests and other helper files, and guide you through a basic configuration necessary for Cypress to operate within your application. - Add the `cypress-cucumber-preprocessor` package to your project.__ This package was originally maintained by BrainFamily, but was recently taken over by Badeball. You can find it here: https://www.npmjs.com/package/@badeball/cypress-cucumber-preprocessor Use the command npm install --save-dev @badeball/cypress-cucumber-preprocessor` You can find the quick start guide here: https://github.com/badeball/cypress-cucumber-preprocessor/blob/a2702f5ce247c96269a0432358ed919b596a4dbb/docs/quick-start.md - Add @bahmutov/cypress-esbuild-preprocessor esbuild__ https://github.com/bahmutov/cypress-esbuild-preprocessor. Use the command `npm i -D @bahmutov/cypress-esbuild-preprocessor esbuild`. This should result in `"esbuild": "^0.15.13"` as well as `"@bahmutov/cypress-esbuild-preprocessor": "^2.1.5"` becoming part of the `devDependencies` in your `package.json` file. Add `cypress/webpack-preprocessor` to your project.__ Use the command `npm i --save-dev @cypress/webpack-preprocessor` to add this package. The `devDependencies` section of your `package.json` file should now include, `"@cypress/webpack-preprocessor": "^5.15.3"` if done correctly. - Configure `cypress.config.ts` file.__ Configure specPattern with "**/*.feature" and setupNodeEvents. Here is my file: `js const { defineConfig } = require("cypress"); const createBundler = require("@bahmutov/cypress-esbuild-preprocessor"); const addCucumberPreprocessorPlugin = require("@badeball/cypress-cucumber-preprocessor").addCucumberPreprocessorPlugin; const createEsbuildPlugin = require("@badeball/cypress-cucumber-preprocessor/esbuild").createEsbuildPlugin; module.exports = defineConfig({ e2e: { async setupNodeEvents(on, config) { const bundler = createBundler({ plugins: [createEsbuildPlugin(config)], }); on("file:preprocessor", bundler); await addCucumberPreprocessorPlugin(on, config); return config; }, specPattern: "cypress/e2e/features/.feature", baseUrl: "http://localhost:3000/SnapShot#/SnapScout/", chromeWebSecurity: false, }, }); ` Additionally, you will want to generate a cypress-cucumber-preprocessorrc.json` file at the root level of your project in order to handle some additional configuration settings. My file contents are below: ` { "json": { "enabled": false, "output": "jsonlogs/log.json", "formatter": "cucumber-json-formatter.exe" }, "messages": { "enabled": false, "output": "jsonlogs/messages.ndjson" }, "html": { "enabled": false }, "stepDefinitions": [ "[filepath]//*.{js,ts}", "[filepath].{js,ts}", "cypress/e2e/stepdefinitions/*.{js,ts}", "[filepath]\\*.{js,ts}", "[filepath].{js,ts}", "cypress\\e2e\\stepdefinitions\\*.{js,ts}" ] } ` Organize your files.__ There are multiple ways to organize your feature files and step definition files. For the purposes of this tutorial, we will place our feature files in a `cypress > e2e > features directory`, and our step definition files in a `cypress > e2e > step_definitions` directory. Let's Write Some Tests!! Let's create a simple feature file with some basic scenarios and test steps for this application. Here is a start in app.feature`! ` Feature: Snapshot website test scenarios Scenario: visiting the home page - successful search Given I visit the home page Then the header should be visible Then I click in the text input Then I type birds into the search input and press search button Then I should see Birds Images in header Then I click the Food button and see Food header and 24 images ` ` import { Given, Then } from "@badeball/cypress-cucumber-preprocessor"; Given("I visit the home page", () => { cy.visit("/"); }); Then("the header should be visible", () => { cy.get("h1").should("be.visible"); }); Then("I click in the text input", () => { cy.get("input").click(); }); Then("I type birds into the search input and press search button", () => { cy.get("input").type("birds"); cy.get(".search-button").click(); }); Then("I should see Birds Images in header", () => { cy.get(":nth-child(24) > img").should("be.visible"); }); Then("I click the Food button and see Food header and 24 images", () => { cy.get(":nth-child(4) > a").click({ force: true }); cy.get(":nth-child(24) > img").should("be.visible"); }); ` Summary As you can see, setting up Cypress with Cucumber is a fairly straight-forward process. There are incredible benefits to using Cucumber with Cypress, particularly related to facilitating communication between non-technical and technical members of a development team, as well as creating ongoing project documentation, and an easy-to-understand guide for what your application or feature is doing....

Common Patterns and Nuances Using React Query cover image

Common Patterns and Nuances Using React Query

React hosts a number of solutions to various design problems due to its flexible paradigm. The decisions we make at the design and architecture phase of a project can either alleviate the time cost of development for a simple robust solution, or hinder it due to taxing implementations. One easy-to-implement yet sometimes tricky to use tool is react-query`- a powerful library for asynchronous state management. It's simplicity in implementation makes it a desireable choice for writing component state logic. However, there are some unspoken aspects of React Query that may seem frustrating, and increase its difficulty, yet often fall on the context of the problem we hope to resolve. The next few concepts demonstrate some patterns for simple solutions while addressing some nuances one might encounter along the way. Note that react-query` is now TanStack Query, and these concepts can be used in Vue, Solid, and Svelte. It'll continue to be referred to as React Query (RQ) in this React article. Understanding Query State Keys Under the hood, RQ performs some mappings similarly to "typed" actions works in other state machines. Actions, or in our case, queries, are mapped to a key where the value is either null`, or some initial state (more on this in the following section). Because RQ is a state management tool, state will update relative to other stateful changes within a component. This means that whenever state changes in a component, these changes also affect the query's state performing state checks, and update accordingly. Take a Todo app that loads some tasks. The snapshot of the query's state can look like: `typescript { queryKey: { 0: 'tasks }, queryHash: "[\"tasks\"]" } ` If the state key changes, so does the cached data accessible at the key at that moment. State changes will only occur on a few select events. If the state key doesn't change, the query can still run, but the cached data won't update. Using Initial and Placeholder Data An example of this is with the use of initial data to represent values that haven't been fully loaded. It's common in state machines to introduce an initial state before hydration). Take the Todo app earlier that needs to show an initial loading state. On initial query, the tasks query is in loading` status (aka `isLoading = true`). The UI will flicker this content in place when it's ready. This isn't the best UX, but it can be fixed quickly. RQ provides options for settings initialData` or `placeholderData`. Although these properties have similarities, the difference is where caching occurs: cache-level vs observer-level. Cache-level** refers to caching via Query Key, which is where `initialData` resides. This initial cache overrides observer-level caches. Observer-level** refers to the location the subscription lives, and where `placeholderData` renders. Data at this level won't cache, and works if no initial data was cached. With initialData`, you have more control over cache `staleTime` and refetching strategies. Whereas, `placeholderData` is a valid option for a simple UX enhancement. Keep in mind that error states change with the choice between caching initial data or not. `typescript export default function TasksComponent() { const { data, isPlaceholderData } = useQuery( ['tasks'], getTasks, { placeholderData: { tasks: [], }, } ); return ( {isPlaceholderData ? ( Initial Placeholder ) : ( {data?.tasks?.map((task) => ( {task.name} ))} )} ); } ` Managing Refreshing State Expanding on the previous concept, updating the query happens when either state is stale, and we control the refresh, or we use other mechanisms, provided by RQ, to perform the same tasks. Re-hydrating Data RQ exposes a refresh` method that makes a new network request for the query and updates state. However, if the cached state is the same as the newly fetched state, nothing will update. This can be a desired outcome to limit state updates, but take a situation where manual refetching is required once a mutation occurs. Using a query key with fetch queries increases the query's specificity, and adds control refetching. This is known as Invalidation*, where the Query Key's data is marked as stale, cleared, and refetched. Refetching data can occur automatically or manually in varying degree for either approach. What this means is we can automatically have a query refetch data on stateTime` or other refetching options (`refetchInterval`, `refetchIntervalInBackground`, `refetchOnMount`, `refetchOnReconnect`, and `refetchOnWindowFocus`). And we can refetch manually with the `refetch` function. Automatic refresh is rather straightforward, but there are times when we want to manually trigger an update to a specific Query Key thus fetching the latest data. However, you probably encountered a situation where the refetch` function doesn't perform a network request. Processing HTTP Errors One of the more common situations beginners to RQ face is handling errors returned from failed http requests. In a standard fetch request using useState` and `useEffect`, it is common to create some state to manage network errors. However, RQ can capture runtime errors if the API handler used doesn't contain proper error-handling. Whether runtime or network error, they appear in the error` property of the query or mutation (also indicated by `isError` status). To overcome this and pass network error into the query, we can either do one or a combination of: - telling the fetch API how to process the failed response - using error boundaries with queries and mutations Handling Errors with Fetch For a simple solution to handling errors, process network requests from the fetch API (or axios) like the following: `typescript async function getTasks() { try { const data = await fetch('https://example.com/tasks'); // if data is possibly null if(!data) { throw new Error('No tasks found') } return data; } catch (error) { throw new Error('Something went wrong') } } ` Then, in your component: `typescript export default function TasksComponent() { const { data, isError } = useQuery(['tasks'], getTasks); return ( {isError ? ( Unable to load errors at this time. ) : ( {data?.tasks?.map((task) => ( {task.name} ))} )} ); } ` This pattern gets repetitive, but is useful for simple apps that may not require heavy query use. Handling Errors with Error Boundaries Arguably the best thing a React dev can do for their app is to set an Error Boundary. Error Boundaries help contain runtime errors that would normally crash an app by propagating through the component tree. However, they can't process network errors without some setup. Thankfully, RQ makes this very easy with the useErrorBoundary` option: `typescript const { data, isError } = useQuery(['tasks'], getTasks, { useErrorBoundary: true }); ` The query hook takes the error, caches it, and rethrows it, so the error boundary captures it accordingly. Additionally, passing a function to useErrorBoundary` that returns a boolean increases the granularity of network error handling like: `typescript const { data, isError } = useQuery(['tasks'], getTasks, { useErrorBoundary: (error) => error.response?.status >= 500 }); ` Takeaways The three main concepts using React Query are: - hydrating the cache with placeholders or defaults - re-hydrating the cache with fresh data - handling network errors with the correct setup and error boundaries There are a number of state management tools to use with React, but React Query makes it simple to get up and running with an effective tool that adheres to some simple patterns and React rendering cycles. Learning how and when to execute queries to achieve the desire pattern takes some understanding of the React ecosystem....

How to Setup Storybook in a Qwik Project cover image

How to Setup Storybook in a Qwik Project

Introduction Storybook is a great tool for testing and visualizing your components in different states. In this article, we will see how to setup Storybook in a Qwik project. Qwik Qwik is a new JavaScript framework by Misko Hevery, creator of Angular, for building frontend browser applications. The major benefit of Qwik is its performance optimization, and this is achieved through zero loading, resumability, lazy loading, reduced rendering, scalability, and code once. For more information on Qwik, you can check out the docs, github repo, and discord. Project Set Up To get started, we need to create a new Qwik app. We can do this by running the following command in our terminal: `shell npm create qwik@latest ` Initialize Storybook Storybook, unfortunately, doesn’t have a Qwik template yet because it is a new Framework. So the work around is to use the html` template . Using the command below, we can initialize Storybook: `shell npx storybook init --type html ` During the initialization, Storybook would ask to automatically install some optional dependencies such as; EslintPlugin and npm7, you can accept or reject it. To accept, type y` and hit `Enter` to progress. Storybook would try to setup a default stories` folder, which is based on the ```–type``` template we choose, which is ```html```. It is not compatible with the Qwik compiler and will result in compilation errors, so we will have to delete it to avoid running into such errors. Project Structure Our project structure is already setup by Qwik, and Storybook initialization has created a .storybook` folder. But we need to make some changes to the Storybook file extension since our project is in TypeScript. This is a snippet of the folders in our project: `text ├── .storybook ├── main.ts ├── preview.ts └── preview-head.html ├── public ├── vite.config.ts └── src ` Configuring Storybook Since Qwik runs on Vite, we need to set up the viteFinal function in our main.ts file, which will give us the config that we will use to register our Qwik Vite plugin. Add this line of code in the configuration object: `js viteFinal: async (config, options) => { const { qwikVite: qwikVite } = await import('@builder.io/qwik/optimizer'); config.plugins?.unshift(qwikVite()); return config; }, ` In the preview.ts file, this is where we configure how Storybook renders our stories. We need to execute Qwikloader. This will help in registering global browser events, and much more Qwik related benefits. We will replace the content in the file with the code below: `js import { JSXNode } from '@builder.io/qwik'; import { QWIKLOADER } from '@builder.io/qwik/loader/index'; import { render } from '@builder.io/qwik'; import '../src/global.css'; eval(QWIKLOADER); export const decorators = [ (Story: () => JSXNode) => { const parent = document.createElement('div'); const jsxNode = Story(); render(parent, jsxNode); return parent; }, ]; ` This solution was found in this discussion: How to do component testing with Qwik?. I believe when the Qwik Storybook type template becomes available these configurations will be there by default. Now we are done with the configuration, let's run storybook and see what we have: `shell npm run storybook ` Creating Stories We can create our first story, we will create a story for our Qwik app component. We will create this story for our default Qwik Header component. I modified the Header component to accept a menus props: `js import { Meta } from '@storybook/html'; import Header, { HeaderProps } from './header'; export default { title: 'Header', } as Meta; const Template = (args: HeaderProps) => ; export const Demo: any = Template.bind({ menus: [] }); Demo.args = { menus: [ { name: 'Docs', link: 'https://qwik.builder.io/docs/components/overview/', }, { name: 'Examples', link: 'https://qwik.builder.io/examples/introduction/hello-world/', }, { name: 'Tutorials', link: 'https://qwik.builder.io/tutorial/welcome/overview/', }, ] }; ` Conclusion In this article, we saw how to setup Storybook in a Qwik project. We also saw how to create our first story. I hope you enjoyed this article. Thanks for reading. If you don't want to do these steps yourself, check out our starter.dev Qwik kit that already has Storybook enabled for your use here. A link to the project repo can be found here. If you have any questions or run into any trouble, feel free to reach out on Twitter....

Testing a Fastify app with the NodeJS test runner cover image

Testing a Fastify app with the NodeJS test runner

Introduction Node.js has shipped a built-in test runner for a couple of major versions. Since its release I haven’t heard much about it so I decided to try it out on a simple Fastify API server application that I was working on. It turns out, it’s pretty good! It’s also really nice to start testing a node application without dealing with the hassle of installing some additional dependencies and managing more configurations. Since it’s got my stamp of approval, why not write a post about it? In this post, we will hit the highlights of the testing API and write some basic but real-life tests for an API server. This server will be built with Fastify, a plugin-centric API framework. They have some good documentation on testing that should make this pretty easy. We’ll also add a SQL driver for the plugin we will test. Setup Let's set up our simple API server by creating a new project, adding our dependencies, and creating some files. Ensure you’re running node v20 or greater (Test runner is a stable API as of the 20 major releases) Overview `index.js` - node entry that initializes our Fastify app and listens for incoming http requests on port 3001 `app.js` - this file exports a function that creates and returns our Fastify application instance `sql-plugin.js` - a Fastify plugin that sets up and connects to a SQL driver and makes it available on our app instance Application Code A simple first test For our first test we will just test our servers index route. If you recall from the app.js` code above, our index route returns a 501 response for “not implemented”. In this test, we're using the createApp` function to create a new instance of our Fastify app, and then using the `inject` method from the Fastify API to make a request to the `/` route. We import our test utilities directly from the node. Notice we can pass async functions to our test to use async/await. Node’s assert API has been around for a long time, this is what we are using to make our test assertions. To run this test, we can use the following command: By default the Node.js test runner uses the TAP reporter. You can configure it using other reporters or even create your own custom reporters for it to use. Testing our SQL plugin Next, let's take a look at how to test our Fastify Postgres plugin. This one is a bit more involved and gives us an opportunity to use more of the test runner features. In this example, we are using a feature called Subtests. This simply means when nested tests inside of a top-level test. In our top-level test call, we get a test parameter t` that we call methods on in our nested test structure. In this example, we use `t.beforeEach` to create a new Fastify app instance for each test, and call the `test` method to register our nested tests. Along with `beforeEach` the other methods you might expect are also available: `afterEach`, `before`, `after`. Since we don’t want to connect to our Postgres database in our tests, we are using the available Mocking API to mock out the client. This was the API that I was most excited to see included in the Node Test Runner. After the basics, you almost always need to mock some functions, methods, or libraries in your tests. After trying this feature, it works easily and as expected, I was confident that I could get pretty far testing with the new Node.js core API’s. Since my plugin only uses the end method of the Postgres driver, it’s the only method I provide a mock function for. Our second test confirms that it gets called when our Fastify server is shutting down. Additional features A lot of other features that are common in other popular testing frameworks are also available. Test styles and methods Along with our basic test` based tests we used for our Fastify plugins - `test` also includes `skip`, `todo`, and `only` methods. They are for what you would expect based on the names, skipping or only running certain tests, and work-in-progress tests. If you prefer, you also have the option of using the describe` → `it` test syntax. They both come with the same methods as `test` and I think it really comes down to a matter of personal preference. Test coverage This might be the deal breaker for some since this feature is still experimental. As popular as test coverage reporting is, I expect this API to be finalized and become stable in an upcoming version. Since this isn’t something that’s being shipped for the end user though, I say go for it. What’s the worst that could happen really? Other CLI flags —watch` - https://nodejs.org/dist/latest-v20.x/docs/api/cli.html#--watch —test-name-pattern` - https://nodejs.org/dist/latest-v20.x/docs/api/cli.html#--test-name-pattern TypeScript support You can use a loader like you would for a regular node application to execute TypeScript files. Some popular examples are tsx` and `ts-node`. In practice, I found that this currently doesn’t work well since the test runner only looks for JS file types. After digging in I found that they added support to locate your test files via a glob string but it won’t be available until the next major version release. Conclusion The built-in test runner is a lot more comprehensive than I expected it to be. I was able to easily write some real-world tests for my application. If you don’t mind some of the features like coverage reporting being experimental, you can get pretty far without installing any additional dependencies. The biggest deal breaker on many projects at this point, in my opinion, is the lack of straightforward TypeScript support. This is the test command that I ended up with in my application: I’ll be honest, I stole this from a GitHub issue thread and I don’t know exactly how it works (but it does). If TypeScript is a requirement, maybe stick with Jest or Vitest for now 🙂...