gnikyt Code ramblings.

Prisma, Jest, and MySQL testing

This is an alternate extension to my previous post, Shopify, Remix, Prisma, and Jest.

Follow that post to get to its current state, and we’ll modify the existing files to support MySQL.

Changes #

Change app/__mocks__/db.server.ts to:

/* app/__mocks__/db.server.ts */

// eslint-disable-next-line import/no-extraneous-dependencies
import { afterEach, beforeEach } from "@jest/globals";
import { PrismaClient } from "@prisma/client";
import { execSync } from "child_process";
import { existsSync } from "fs";
import * as path from "path";

/**
 * Run an NPM command.
 *
 * @param script - Script to run.
 */
function npm(script: string): Buffer {
  return execSync(["npm", "run", script].join(" "), {
    env: {
      ...process.env,
      NODE_ENV: "test",
      DATABASE_URL: process.env.DATABASE_URL,
    },
  });
}

/**
 * Seeds the database with initial data.
 */
function seedDatabase() {
  return Promise.allSettled([
    // Seed shop session
    prisma.session.create({
      data: {
        id: "example.myshopify.com_id",
        shop: "example.myshopify.com",
        accessToken: "token",
        state: "",
        isOnline: false,
      },
    }),
    // Add more seed data
  ]);
}

// Path to generated types
const typesPath = path.resolve("node_modules", ".prisma", "client", "index.d.ts");
if (!existsSync(typesPath)) {
  npm("test:db:generate");
}

// Prisma setup... generate a unique database for MySQL, override existing Prisma implementation via Jest mock
const dbName = `test-${new Date().getTime()}`;
process.env.DATABASE_URL = `${process.env.DATABASE_URL}/${dbName}`;
const prisma = new PrismaClient({
  datasources: { db: { url: process.env.DATABASE_URL } },
});

beforeEach(async () => {
  npm("test:db:deploy");
  await seedDatabase();
});

afterEach(async () => {
  // Drop the database and disconnect
  await prisma.$executeRawUnsafe(`DROP DATABASE \`${dbName}\`;`);
  await prisma.$disconnect();
});

export default prisma;

This will create a unique database in MySQL for each test, then destroy it.

Example #

You reference the existing db.server.ts file normally in your tests, as Jest will swap it out with our __mocks__/db.server.ts version.

/* app/order/processor.test.ts */

import { describe, expect, it } from "@jest/globals";
import db from "../db.server";
import { prismaHooks } from "../mocks.prisma";
import { ShopifyClient, ShopifyFixture } from "../mocks.clients";
// etc...

/* just an example... */

describe("order processor", () => {
  it("should insert order", async () => {
    const orderId = OrderId("gid://shopify/Order/1");
    const customerId = CustomerId("gid://shopify/Customer/1");
    const product1 = ProductId("gid://shopify/Product/1");
    const prodcut2 = ProductId("gid://shopify/Product/2");

    // Job data
    const jobData = {
      order: {
        id: orderId.toInt(),
        admin_graphql_id: orderId.toGql(),
        tags: [],
        total_price: "115.00",
        customer: {
          id: customerId.toInt(),
        },
        line_items: [
          {
            product_id: p1.toInt(),
            price: "100.00",
            quantity: 1,
          },
          {
            product_id: p2.toInt(),
            price: "15.00",
            quantity: 1,
          },
        ],
      },
      shop: "example.myshopify.com",
    };

    // Shopify GraphQL mock client
    const client = ShopifyClient(
      ShopifyFixture("product-no-tag", "product/no-override-tag"),
      ShopifyFixture("product-with-tag", "product/with-override-tag"),
      ShopifyFixture("add-tag", "tags-add-customer"),
    );

    // Run the processor
    const [retOrderId, retOustomerId, retSum]: JobReturn = await processor({
      db,
      client,
      job: { data: jobData },
    });
    expect(retOrderId.isSame(orderId)).toBe(true);
    expect(retCustomerId.isSame(customerId)).toBe(true);
    expect(retSum.isSame(Cents(3000)));
  });
});

Usage #

You would need to pass in a database URL for connection parameters for MySQL, except for the database name, as we generate that normally:

DATABASE_URL=mysql://root:password@localhost:3306 npm run test --runInBand

Docker / Docker-Compose #

If you would like to utilize containers for the testing, this is doable as well.

Create a Dockerfile-test and docker-compose.test.yml in your app root.

# Dockerfile-test

# Stage 1: Node modules installation
FROM node:18-alpine as testcache

WORKDIR /cache

# Copy in package JSONs, and Prisma must be copied in before running install
COPY package-lock.json .
COPY package.json .
COPY prisma ./prisma

RUN npm install

# Stage 2: Test
FROM node:18-alpine as test

WORKDIR /app

EXPOSE 3000

# Set initial ENVs, can be overwritten in docker-compose.test.yml
ENV NODE_ENV=test
ENV DATABASE_URL=mysql://root:password@db:3306

# Copy in only files we need to complete the tests
COPY app ./app
COPY fixtures ./fixtures
COPY jest.config.js ./jest.config.js
COPY tsconfig.json ./tsconfig.json
COPY --from=testcache /cache .

CMD ["npm", "run", "test", "--", "--verbose", "--runInBand"]

This Dockerfile uses Alpine Linux with two stage build… one to install dependencies and cache them, and a second step which uses the cached dependencies and runs the tests.

version: '3.9'

services:
  db:
    image: mysql:8.0
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: "password"
    ports:
      - 3306:3306
    expose:
      - 3306
    volumes:
      - dbdata:/var/lib/mysql
  test:
    build:
      context: .
      dockerfile: Dockerfile-test
    depends_on:
      - db
    environment:
      - NODE_ENV=test
      - DATABASE_URL=mysql://root:password@db:3306
    volumes:
      - ./jest.config.js:/app/jest.config.js
      - ./app:/app/app
      - ./prisma:/app/prisma

volumes:
  dbdata:

This Docker Compile file will setup MySQL v8 with a basic password and expose the port, as well as persist our database volume.

It will also setup the app to test, mounting all needed files we need to run the tests.

Then, docker-compose -f docker-compose.test.yml up --exit-code-from test to run it.

The first time you run it, it may take time as the cache needs to be built, but after that its super fast in booting and starting the tests.

The --exit-code-from test is needed so that Docker Compose will shut down once Jest exits. Without this, the container stays open and running.