Skip to content

Add aroundEach Hook Similar to RSpec around #5728

@klondikemarlen

Description

@klondikemarlen

Clear and concise description of the problem

I want an aroundEach so that I can run my database tests inside a transaction and roll back the transaction after the test completes. This would vastly speed up my test runs.

Currently I need to wipe each table in my database, on each test run, to be sure that test state is clean.
Naturally, due to having a great many migrations, this is more performant than dropping the whole database and then rebuilding the schema.

code I want to replace with aroundEach wrapped in a transaction
import dbLegacy from "@/db/db-client-legacy"

async function getTableNames() {
  const query = /* sql */ `
    SELECT
      table_name AS "tableName"
    FROM
      information_schema.tables
    WHERE
      table_schema = 'dbo'
      AND table_type = 'BASE TABLE'
      AND table_name NOT IN ('knex_migrations' , 'knex_migrations_lock');
  `

  try {
    const result = await dbLegacy.raw<{ tableName: string }[]>(query)
    const tableNames = result.map((row) => row.tableName)
    return tableNames
  } catch (error) {
    console.error("Error fetching table names:", error)
    throw error
  }
}

async function getTableNamesWithoutIdentityColumn() {
  const query = /* sql */ `
    SELECT
      tables.name AS "tableName"
    FROM
      sys.tables AS tables
    WHERE
      tables.schema_id = SCHEMA_ID('dbo')
      AND NOT EXISTS (
        SELECT 1
        FROM sys.columns AS columns
        WHERE columns.object_id = tables.object_id AND columns.is_identity = 1
      );
  `

  try {
    const result = await dbLegacy.raw<{ tableName: string }[]>(query)
    const tableNames = result.map((row) => row.tableName)
    return tableNames
  } catch (error) {
    console.error("Error fetching table names without identity columns:", error)
    throw error
  }
}

/**
 * Example of generated SQL commands used for cleaning database:
 *
 * ```sql
 * ALTER TABLE table1 NOCHECK CONSTRAINT ALL;
 * ALTER TABLE table2 NOCHECK CONSTRAINT ALL;
 * ...
 * DELETE FROM table1 WHERE 1=1;
 * DELETE FROM table2 WHERE 1=1;
 * ...
 * DBCC CHECKIDENT ('table1', RESEED, 0);
 * DBCC CHECKIDENT ('table2', RESEED, 0);
 * ...
 * ALTER TABLE table1 CHECK CONSTRAINT ALL;
 * ALTER TABLE table2 CHECK CONSTRAINT ALL;
 * ...
 * ```
 */
async function buildCleanupQuery() {
  const tableNames = await getTableNames()
  const tableNamesWithoutIdentityColumn = await getTableNamesWithoutIdentityColumn()
  const tableNamesWithIdentityColumn = tableNames.filter(
    (tableName) => !tableNamesWithoutIdentityColumn.includes(tableName)
  )
  const disableAllConstraintsQuery = tableNames
    .map((tableName) => /* sql */ `ALTER TABLE ${tableName} NOCHECK CONSTRAINT ALL;`)
    .join("\n")
  const deleteAllInAllTablesQuery = tableNames
    .map((tableName) => /* sql */ `DELETE FROM ${tableName} WHERE 1=1;`)
    .join("\n")
  const resetIdentityColumnsQuery = tableNamesWithIdentityColumn
    .map((tableName) => /* sql */ `DBCC CHECKIDENT ('${tableName}', RESEED, 0);`)
    .join("\n")
  const enableAllConstraintsQuery = tableNames
    .map((tableName) => /* sql */ `ALTER TABLE ${tableName} CHECK CONSTRAINT ALL;`)
    .join("\n")
  const cleanupQuery = [
    disableAllConstraintsQuery,
    deleteAllInAllTablesQuery,
    resetIdentityColumnsQuery,
    enableAllConstraintsQuery,
  ].join("\n")
  return cleanupQuery
}

async function cleanDatabase() {
  const cleanupQuery = await buildCleanupQuery()
  try {
    await dbLegacy.raw(cleanupQuery).catch(console.error)
    return true
  } catch (error) {
    console.error(error)
    return false
  }
}

beforeEach(async () => {
  await cleanDatabase()
})

It's also possible that this is already possible, but after spending an entire day trying to get it to work, I'm at least reasonably sure it isn't.

Suggested solution

Add support for a simple aroundEach hook.

// sequelize being a sequelize 7 (or 6) instance that is using managed transactions

aroundEach(async (example) => {
   await sequelize.transaction(async (transaction) => {
     await example.run()
     await transaction.rollback()
   })
})

This would vastly speed up my test runs from

 tests/services/position-teams/create-service.test.ts (3) 1968ms
   ✓ api/src/services/position-teams/create-service.ts (3) 1967ms
     ✓ CreateService (3) 1967ms
       ✓ #perform (3) 1967ms
         ✓ when passed valid attributes, it creates a position team 770ms
         ✓ when teamId is missing, errors informatively 614ms
         ✓ when positionId is missing, errors informatively 582ms

To something much more reasonable.

Its true that I can do this in each test, but who wants to clutter up their test code with setup logic?

Alternative

Implement my own aroundEach that looks like

async function aroundEach(fn: (runExample: () => Promise<void>) => Promise<void>) {
  beforeEach(async () => {
    console.log("beforeEach start")
    let resolveBeforeEachWaiter: (value: void | PromiseLike<void>) => void
    const exampleRunWaiter = new Promise<void>((resolve) => {
      resolveBeforeEachWaiter = resolve
    })

    fn(() => exampleRunWaiter)

    return async () => {
      console.log("beforeEach cleanup start")
      resolveBeforeEachWaiter()
      await exampleRunWaiter
      console.log("beforeEach cleanup end")
    }
  })
}

aroundEach(async (runExample) => {
  console.log("aroundEach start")
  await db.transaction(async () => {
    console.log('before runExample')
    await runExample()
    // TODO: implement rollback
    console.log('after runExample')
  })
  console.log("aroundEach end")
})

Additional context

Will be used in all of the projects I'm working; pretty much anything in https://github.com/orgs/icefoganalytics/repositories?type=public (subset of https://github.com/orgs/ytgov/repositories?type=all).

Example of tests that would benefit from beforeEach transaction wrapping
import { UserTeam } from "@/models"
import { CreateService } from "@/services/user-positions"
import {
  organizationFactory,
  positionFactory,
  positionTeamFactory,
  teamFactory,
  userFactory,
} from "@/factories"

describe("api/src/services/user-positions/create-service.ts", () => {
  describe("CreateService", () => {
    describe("#perform", () => {
      test("when passed valid attributes, it creates a user position", async () => {
        // Arrange
        const user = await userFactory.create()
        const organization = await organizationFactory.create()
        const position = await positionFactory.create({
          organizationId: organization.id,
        })
        const attributes = {
          userId: user.id,
          positionId: position.id,
        }

        // Act
        const userPosition = await CreateService.perform(attributes)

        // Assert
        expect(userPosition).toEqual(
          expect.objectContaining({
            userId: user.id,
            positionId: position.id,
          })
        )
      })

      test("when userId is missing, errors informatively", async () => {
        // Arrange
        const organization = await organizationFactory.create()
        const position = await positionFactory.create({
          organizationId: organization.id,
        })
        const attributes = {
          positionId: position.id,
        }

        // Assert
        expect.assertions(1)
        await expect(
          // Act
          CreateService.perform(attributes)
        ).rejects.toThrow("User ID is required")
      })
    })

    test("when positionId is missing, errors informatively", async () => {
      // Arrange
      const user = await userFactory.create()
      const attributes = {
        userId: user.id,
      }

      // Assert
      expect.assertions(1)
      await expect(
        // Act
        CreateService.perform(attributes)
      ).rejects.toThrow("Position ID is required")
    })

    test("when position has teams, it creates user teams for all teams position has access to", async () => {
      // Arrange
      const user = await userFactory.create()
      const organization = await organizationFactory.create()
      const position = await positionFactory.create({
        organizationId: organization.id,
      })
      const team1 = await teamFactory.create({
        organizationId: organization.id,
      })
      const team2 = await teamFactory.create({
        organizationId: organization.id,
      })
      const team3 = await teamFactory.create({
        organizationId: organization.id,
      })
      const positionTeam1 = await positionTeamFactory.create({
        teamId: team1.id,
        positionId: position.id,
      })
      const positionTeam2 = await positionTeamFactory.create({
        teamId: team2.id,
        positionId: position.id,
      })
      const positionTeam3 = await positionTeamFactory.create({
        teamId: team3.id,
        positionId: position.id,
      })
      const attributes = {
        userId: user.id,
        positionId: position.id,
      }

      // Act
      const userPosition = await CreateService.perform(attributes)

      // Assert
      expect(userPosition).toEqual(
        expect.objectContaining({
          userId: user.id,
          positionId: position.id,
        })
      )
      const userTeams = await UserTeam.findAll({
        where: { userId: user.id },
        order: [["teamId", "ASC"]],
      })
      expect(userTeams).toHaveLength(3)
      expect(userTeams).toEqual([
        expect.objectContaining({
          userId: user.id,
          teamId: team1.id,
          positionId: position.id,
          positionRole: positionTeam1.role,
          sources: UserTeam.Sources.POSITION,
        }),
        expect.objectContaining({
          userId: user.id,
          teamId: team2.id,
          positionId: position.id,
          positionRole: positionTeam2.role,
          sources: UserTeam.Sources.POSITION,
        }),
        expect.objectContaining({
          userId: user.id,
          teamId: team3.id,
          positionId: position.id,
          positionRole: positionTeam3.role,
          sources: UserTeam.Sources.POSITION,
        }),
      ])
    })

    test("when position has teams and user is already on a team, it creates user teams for all teams position has access to and does not duplicate existing user teams", async () => {
      // Arrange
      const user = await userFactory.create()
      const organization = await organizationFactory.create()
      const position = await positionFactory.create({
        organizationId: organization.id,
      })
      const team1 = await teamFactory.create({
        organizationId: organization.id,
      })
      const team2 = await teamFactory.create({
        organizationId: organization.id,
      })
      const team3 = await teamFactory.create({
        organizationId: organization.id,
      })
      const positionTeam1 = await positionTeamFactory.create({
        teamId: team1.id,
        positionId: position.id,
        role: "Role 1",
      })
      const positionTeam2 = await positionTeamFactory.create({
        teamId: team2.id,
        positionId: position.id,
        role: "Role 2",
      })
      const positionTeam3 = await positionTeamFactory.create({
        teamId: team3.id,
        positionId: position.id,
        role: "Role 3",
      })
      await UserTeam.create({
        userId: user.id,
        teamId: team1.id,
        sources: UserTeam.Sources.DIRECT,
      })
      await UserTeam.create({
        userId: user.id,
        teamId: team2.id,
        sources: UserTeam.Sources.DIRECT,
      })
      const attributes = {
        userId: user.id,
        positionId: position.id,
      }

      // Act
      const userPosition = await CreateService.perform(attributes)

      // Assert
      expect(userPosition).toEqual(
        expect.objectContaining({
          userId: user.id,
          positionId: position.id,
        })
      )
      const userTeams = await UserTeam.findAll({
        where: { userId: user.id },
        order: [["teamId", "ASC"]],
      })
      expect(userTeams).toHaveLength(3)
      expect(userTeams).toEqual([
        expect.objectContaining({
          userId: user.id,
          teamId: team1.id,
          positionId: position.id,
          positionRole: positionTeam1.role,
          sources: UserTeam.Sources.DIRECT_AND_POSITION,
        }),
        expect.objectContaining({
          userId: user.id,
          teamId: team2.id,
          positionId: position.id,
          positionRole: positionTeam2.role,
          sources: UserTeam.Sources.DIRECT_AND_POSITION,
        }),
        expect.objectContaining({
          userId: user.id,
          teamId: team3.id,
          positionId: position.id,
          positionRole: positionTeam3.role,
          sources: UserTeam.Sources.POSITION,
        }),
      ])
    })
  })
})

Validations

Metadata

Metadata

Assignees

No one assigned

    Labels

    p2-nice-to-haveNot breaking anything but nice to have (priority)

    Projects

    Status

    Approved

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions