@getcircuit/firestore-test
This package is comprised of a set of utilities to help you write tests for your Firestore code. It mainly provides two significant features:
- A set of utilities to help you seed Circuit's firestore data inside a
jest
test environment. - A tiny CLI to help you get the emulator going with the appropriate configuration for easy testing.
CLI
The CLI is a simple wrapper around the firebase
CLI, but with the project
set to test
and pointing to this package's firebase.json
configuration file.
firestore-test emulator:start
firestore-test emulator:exec "<command>"
Example:
firestore-test emulator:exec "jest"
Configuring the Test Environment
Setting Up the Test Firebase App
The test firebase app is automatically initialized once getTestFirestore
, getTestAuth
or getTestApp
is called.
This package offers a main entry point for two environments: browser
and node
. The appropriate environment to load is automatically detected based on the pkgJson.exports
field. If a browser environment is detected, the firebase instances will be initialized with the firebase
package. If a node environment is detected, the firebase instances will be initialized with the firebase-admin
package.
⚠️ Note: If you opt for the Node SDK, make sure to follow the instructions in the firebase-types
package to override the default Firebase types. This is essential as the default types are set for the browser SDK.
Configuring Jest
@getcircuit/firestore-test
provides pre-configured Jest setup files for both browser and Node.js environments. You can extend these in your project's Jest configuration:
@getcircuit/firestore-test/jest/config.browser
for a browser environment@getcircuit/firestore-test/jest/config.node
for a Node.js environment
When integrating these configurations, make sure to merge the globalSetup
and setupFilesAfterEnv
fields to avoid overwriting any existing configurations in your project's Jest setup.
const sharedConfig = require('@getcircuit/firestore-test/jest/config.browser')
module.exports = {
...sharedConfig,
globalSetup: [
...(sharedConfig?.globalSetup ?? []),
],
setupFilesAfterEnv: [
...(sharedConfig?.setupFilesAfterEnv ?? []),
],
}
Seed utilities
Seeding methods
This package offers three distinct types of utilities to seed data into Firestore, each serving a different purpose and level of complexity:
-
seed{Model}
methods: These methods are your starting point for data initialization. They allow you to create just the basic Firestore document for a given model—no more, no less. Ideal for setting up minimal, isolated test scenarios or manually constructing a non-standard data setup (i.e member with broken user references, etc).
-
populate{Model}
methods: Once you've sown your "seed" with the basic document, you can use these methods to grow it by adding related data. These are particularly useful when you need to build upon a basic setup incrementally. For example, you can populate a team with members and depots after a seedTeam
.
-
bootstrap{Model}
methods: These utilities are designed for comprehensive data setup in a single step, ensuring that all relationships between the data entities are correctly configured. Use bootstrap when you need a fully-integrated, relationally coherent data set.
Available seeding methods:
import {
seedTeam, bootstrapTeam, populateTeam,
seedUser, seedUsers, bootstrapUser, bootstrapUsers,
seedMember, seedMembers, bootstrapMember, bootstrapMembers,
seedPlan, bootstrapPlan, populatePlan, seedPlanDriver, seedPlanDrivers,
seedRoute, seedRoutes, bootstrapRoute, populateRoute,
seedDepot, seedDepots,
seedRecipient, seedRecipients, populateRecipient, bootstrapRecipient,
seedStop, seedStopOnRoot, seedStops, seedStopsOnRoot,
seedPackages, seedPackagesOnRecipient,
seedProducts,
seedPatches,
seedExperiment, seedExperiments,
seedDriverZone, seedDriverZones,
seedBreaks,
seedLocationLogs,
seedShopifySession, seedShopifySessions
} from '@getcircuit/firestore-test';
When to Use Which?
Understanding which utility to use depends on the testing scenario you're facing:
-
Standard, Interconnected Data: If you're looking to simulate a real-world, production-like environment where all data entities maintain standard and correct relationships, bootstrap{Model}
methods are your go-to. They provide a comprehensive, functional data structure out of the box.
-
Edge Cases and Anomalies: When your tests require unconventional or incorrect data relationships—where you need to break the standard links between entities for testing purposes—opt for the seed{Model}
methods. These give you the flexibility to manually construct specific scenarios with granular control over data relationships.
-
Partial Data Extension: If you've already set up some basic data structures using seed{Model}
and want to extend them by adding related data entities, consider using populate{Model}
methods. These are ideal for incrementally building upon an existing setup.
By choosing the appropriate utility, you ensure that your tests are both effective and efficient, whether you're testing core functionalities or edge cases.
Seed Recipes
Even with the flexibility offered by seed
, populate
, and bootstrap
, there are unique test scenarios that these utilities might not cover completely. This is where "Seed Recipes" come into play.
Recipes are normal functions prefixed by prepare
that bootstraps/seeds multiple documents into the database for a given context. In contrast to the seeding utilities provided by this package, recipes may have a loose API and receive or return whatever fits the use case.
They are designed for particular test scenarios that don't fit the one-size-fits-all approach of general-purpose seeding utilities. For example, creating a team with a specific number of members, or a route with a specific number of stops, or create a team/user/route in one go.
Structuring Seed Recipes
We recommend organizing your Seed Recipes in a seed_recipes
folder within your project. Each module in this folder should contain one recipe function, prefixed by prepare
. All the recipes should then be re-exported by the index file of the seed_recipes
folder.
Here's a simple example:
export async function prepareDriverWithRoute() {
}
export * from './prepareDriverWithRoute'
By adhering to these conventions and using Seed Recipes wisely, you can ensure that your Firestore tests are both effective and flexible, capable of handling both general use-cases and unique edge cases.
firebase-admin
exclusive utilities
The seeding utilities are designed to work in both the browser and node environments. However, when in the context of a node environment (firebase-admin
) any bootstrapped user will also have their Auth account created. This is not the case in the browser environment (firebase
), as the browser SDK doesn't allow for creating users directly.
The following utilities are only available in the firebase-admin
environment:
createIdTokenForEmailAndPassword
- Creates an id token for the given email and password.createIdTokenForUID
- Creates an id token for the given uid.seedAccount
- Seeds an account with the given data.findAccount
- Finds an account with the given email or phone number.
Using the seeding utilities
Customizing data
Each utility allows for partial data input through a DeepPartial of the model. This enables you to specify only the data you need for a test, with any unspecified data falling back to defaults set in src/firebase/data-factories
.
For example, each seeded model may or may not receive an id
property. If no id
is provided, a random one will be generated.
await seedStops(collection, [
{ id: 'stop-1', name: 'Stop 1' },
{ name: 'Stop 2' },
])
While there are individual methods to seed users, members, depots, etc, you don't need to use them directly as explained in the when to use which section. Instead, you can use the populateTeam
or boostrapTeam
method to seed a team with all its related data.
For example, to seed a team with members and their respective users:
import {
seedTeam,
populateTeam,
bootstrapTeam,
} from '@getcircuit/firestore-test'
const teamSelectors1 = await seedTeam()
await populateTeam(teamSelectors1.teamRef, {
members: [
{ id: 'john', name: 'John Doe' },
{ id: 'jane', name: 'Jane Doe' },
],
})
const teamSelectors2 = await bootstrapTeam(
{},
{
members: [
{ id: 'john', name: 'John Doe' },
{ id: 'jane', name: 'Jane Doe' },
],
},
)
This will automatically create the users for the specified members, and link their DocumentReferences
accordingly.
We can then select these references with the help of the teamSelectors
object:
teamSelectors.membersRef()
teamSelectors.memberRef('john')
teamSelectors.userRef('john')
Handling Shared user IDs Across Tests
In a testing environment where each test is expected to be isolated and seed its own data, the user
collection is shared among all tests. Importantly, there is no expectation to reset any Firestore data or collections between tests. This presents a risk of ID conflicts when multiple tests attempt to create a user with the same ID via their associated members.
To address this issue, the utilities handle IDs in a specific manner depending on the context:
-
Direct User Seeding: Using seedUser
or seedUsers
directly will not involve any automatic ID prefixing. What you specify is what you get.
-
Seeding via member
: When a user is created as part of member seeding through bootstrapTeam
or populateTeam
, the ID for the user is automatically prefixed with the ID of the team to which the member belongs. This ensures uniqueness across tests.
test('some test', async () => {
const teamSelectors = await bootstrapTeam(
{ id: 'team-1' },
{ members: [{ id: 'john', name: 'John Doe' }] },
)
expect(teamSelectors.memberRef('john').id).toBe('john')
expect(teamSelectors.userRef('john').id).toBe('team-1-john')
})
test('some other test', async () => {
const teamSelectors = await bootstrapTeam(
{ id: 'team-2' },
{ members: [{ id: 'john', name: 'John Doe' }] },
)
expect(teamSelectors.memberRef('john').id).toBe('john')
expect(teamSelectors.userRef('john').id).toBe('team-2-john')
})
If you need to reference this prefixed user ID, the returned teamSelectors
object provides a userId
method for convenience:
teamSelectors.userId('john')
This feature ensures that tests remain isolated in their data setup, thereby making the utilities robust and easy to use.
Handling Cross-References in Seeded Data
Creating cross-references between Firestore documents can be tricky, especially when the documents are being seeded simultaneously. For example, how can you link a member to a depot when both are just about to be created? This challenge arises often when using, for example, populateTeam
or bootstrapTeam
.
There are two main approaches to handle this:
1. Using a Function with populate
or bootstrap
You can use a deferred argument function to dynamically generate the seeding data. The function receives the selectors
as an argument, allowing you to reference previously created documents.
const teamSelectors = await bootstrapTeam({}, (selectors) => ({
depots: [{ id: 'depot-1' }],
members: [
{
id: 'john',
name: 'John Doe',
depot: selectors.depotRef('depot-1'),
},
],
}))
2. Using Seeding Methods Separately
For more granular control, you can use the seeding methods individually. This approach requires manually linking the references.
const teamSelectors = await seedTeam()
const { depotRef } = await seedDepot(teamSelectors.teamRef, {
id: 'depot-1',
name: 'Depot 1',
})
await bootstrapMember(teamSelectors.teamRef, {
id: 'john',
name: 'John Doe',
depot: depotRef,
})
By choosing the appropriate method, you can effectively manage the complexities of document cross-referencing in your Firestore tests.
Other testing utilities
export {
getTestFirestore,
getTestRefSelectors,
getUniqueTestRootRef,
waitForSnapshotListenersInSync,
fakeId,
fakeEmail,
fakePhoneNumber,
fakeShopifySessionId,
FIREBASE_TEST_PROJECT_ID,
} from '@getcircuit/firestore-test'
-
getTestFirestore()
returns a Firestore
instance that is connected to the emulator.
-
getTestRefSelectors()
returns a bag of ref selectors already attached to the test Firestore.
-
getUniqueTestRootRef()
returns a DocumentReference
that is unique to the current test. This is useful when you want to seed data that is specific to the current test without having to seed a whole team.
-
waitForSnapshotListenersInSync()
returns a promise that resolves when all active snapshot listeners are in sync with the remote firestore. This is useful when you want to guarantee that a test is waiting for a firestore document to be updated.
-
fakeId()
returns a random id that can be used to seed data.
-
fakeEmail()
returns a random email that can be used to seed data.
-
fakePhoneNumber()
returns a random phone number that can be used to seed data.
-
fakeShopifySessionId()
returns a random shopify session id that can be used to seed data.
-
FIREBASE_TEST_PROJECT_ID
is the id of the test project. It's _firebase_test_
currently.
-
waitForSnapshotListenersInSync()
is a function that returns a promise that resolves when all active snapshot listeners are in sync with the remote firestore. This is useful when you want to guarantee that a test is waiting for a firestore document to be updated.
Writing tests
Test isolation
Each test that connects to the firestore emulator is expected to be self-contained. This means that each test should seed its own data. Cleaning up is not expected nor necessary.
import { bootstrapTeam, bootstrapRoute } from '@getcircuit/firestore-test'
test('route is created', async () => {
const teamSelectors = await bootstrapTeam(
{},
{
members: [{ id: 'chris', name: 'Chris Doe' }],
},
)
const routeSelectors = await bootstrapRoute(teamSelectors.teamRef, {
route: {
driver: teamSelectors.userRef('chris'),
member: teamSelectors.memberRef('chris'),
},
})
expect('...').toEqual('...')
})
Conflict-free Firestore Root
When your Firestore-related test doesn't require a complete team setup, you can achieve test isolation by using a unique Firestore root for each test. The getUniqueTestRootRef
function provides a convenient way to do this. This function returns a unique document reference pointing to test-suite/{randomId}
, effectively isolating each test's data.
By using this approach, you can prevent conflicts when running multiple tests that may otherwise interact with the same Firestore collection names.
import { getUniqueTestRootRef } from '@getcircuit/firestore-test'
test('test 1', async () => {
const rootRef = getUniqueTestRootRef()
const patches = rootRef.collection('patches')
})
test('test 2', async () => {
const rootRef = getUniqueTestRootRef()
const patches = rootRef.collection('patches')
})
By using getUniqueTestRootRef
, you ensure that each test has its own unique path in Firestore, allowing for conflict-free and independent test execution.