Skip to content

Instantly share code, notes, and snippets.

@cpsubrian
Last active June 13, 2023 06:59
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save cpsubrian/b4820b475e7262251a16fb286606e4f7 to your computer and use it in GitHub Desktop.
Save cpsubrian/b4820b475e7262251a16fb286606e4f7 to your computer and use it in GitHub Desktop.
Mock knex database for Jest
/* eslint-env jest */
import _ from 'lodash'
import path from 'path'
import fs from 'fs'
import callsites from 'callsites'
import knex from 'knex'
import hash from 'object-hash'
import conf from '<conf>'
// Get the db config.
const config = conf.get('database')
// Track jasmine suites and specs.
let suites = []
let specs = []
global.jasmine.getEnv().addReporter({
suiteStarted: (suite) => suites.push(suite),
suiteDone: (suite) => suites.pop(),
specStarted: (spec) => specs.push(spec),
specDone: (spec) => specs.pop()
})
// Helper to get the current spec's fullname, or in absense, the nearest
// suite.
function getSpecName () {
let spec = _.last(specs)
let suite = _.last(suites)
if (spec) {
return spec.fullName
} else if (suite) {
return suite.description
} else {
throw new Error('Not currently in a spec or a suite')
}
}
// Test dbs will be stored here.
const stack = []
stack.ensure = function () {
if (!stack.length) {
stack.unshift(knex(config))
}
}
// Create the knex proxy. This will treat whichever db is at the front
// of the stack as the active one.
const db = new Proxy(function (...args) {
stack.ensure()
return stack[0].apply(stack[0], args)
}, {
get (target, name) {
stack.ensure()
if (!(name in stack[0])) {
console.warn("Getting non-existant property '" + name + "'")
return undefined
}
return stack[0][name]
},
set (target, name, value) {
stack.ensure()
stack[0][name] = value
return true
}
})
// Destroy any accidentally open databases.
afterAll(() => {
while (stack.length) {
stack.shift().destroy()
}
})
// Mock the db.client and run tests with overridable mocks.
function withMockDatabase (tests) {
let mocks = {
_query: jest.fn((conn, obj) => {
return Promise.reject(new Error('Not implemented'))
}),
_stream: jest.fn((conn, obj, stream, options) => {
return Promise.reject(new Error('Not implemented'))
}),
acquireConnection: jest.fn(() => Promise.resolve({})),
releaseConnection: jest.fn(() => Promise.resolve())
}
beforeAll(() => {
stack.ensure()
// Override prototype methods with instance properties.
_.each(mocks, (val, key) => {
db.client[key] = val
})
})
tests(mocks)
afterAll(() => {
// Remove instance properties to restore prototype versions.
_.each(mocks, (val, key) => {
delete db.client[key]
})
})
}
// Inject a real test database for the current test scenario.
function withTestDatabase (tests) {
const name = `ac_test__${Date.now()}_${Math.floor(Math.random() * 100)}`
beforeAll(() => {
return db
.raw(`
CREATE DATABASE :target:
WITH TEMPLATE :template:
OWNER :user:
`, {
template: 'ac_template',
target: name,
user: config.connection.user
})
.then(() => {
let _config = _.cloneDeep(config)
_config.connection.database = name
stack.unshift(knex(_config))
})
})
tests(name)
afterAll(() => {
return stack.shift().destroy().then(() => {
return db.raw('DROP DATABASE ??', [name])
})
})
}
// Store snapshots created in this test run.
const snapshots = {}
// Cache query responses and mock them on subsequent test runs.
function withQuerySnapshots (_filename, tests) {
let dir = path.resolve(path.dirname(_filename), '__fixtures__')
let filename = path.basename(_filename) + '.queries'
let filepath = path.join(dir, filename)
let exists = fs.existsSync(filepath)
let update = typeof process.env.REQUERY !== 'undefined'
if (exists && !update) {
let cached = require(filepath)
withMockDatabase((mocks) => {
mocks._query.mockImplementation((conn, obj) => {
let specName = getSpecName()
let queryHash = hash(obj.sql)
let querySnaps = _.get(cached, [specName, queryHash]) || []
let snapshot = querySnaps.shift()
if (snapshot) {
if (snapshot.error) {
throw _.extend(new Error(snapshot.error.message), snapshot.error.data)
} else {
return Promise.resolve(_.extend({}, obj, snapshot))
}
} else {
throw _.extend(new Error('Could not find snapshot for query'), {obj})
}
})
tests()
})
} else {
withTestDatabase(() => {
beforeAll(() => {
db.on('query-response', function captureSnapshot (rows, obj) {
obj.sql = obj.sql.replace(/\$\d+/g, '?')
let specName = getSpecName()
let queryHash = hash(obj.sql)
let querySnaps = _.get(snapshots, [filepath, specName, queryHash]) || []
let snapshot = _.cloneDeep(_.pick(obj, 'sql', 'bindings', 'response'))
_.set(snapshots, [filepath, specName, queryHash, querySnaps.length], snapshot)
})
db.on('query-error', function captureSnapshot (err, obj) {
obj.sql = obj.sql.replace(/\$\d+/g, '?')
let specName = getSpecName()
let queryHash = hash(obj.sql)
let querySnaps = _.get(snapshots, [filepath, specName, queryHash]) || []
let snapshot = _.cloneDeep(_.pick(obj, 'sql', 'bindings'))
snapshot.error = {message: err.message, data: err}
_.set(snapshots, [filepath, specName, queryHash, querySnaps.length], snapshot)
})
})
tests()
afterAll(() => {
if (_.isEmpty(snapshots[filepath])) {
if (exists) {
fs.unlinkSync(filepath)
if (fs.existsSync(dir) && !fs.readdirSync(dir).length) {
fs.rmdirSync(dir)
}
}
} else {
let obj = JSON.stringify(snapshots[filepath] || {}, null, 2)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir)
}
fs.writeFileSync(filepath, `module.exports = ${obj};`)
}
})
})
}
}
// Return a string with an incrementing count appended.
const counts = {}
function appendCount (str) {
counts[str] = counts[str] ? ++counts[str] : 0
return str + (counts[str] ? `(${counts[str]})` : '')
}
// Extend the global describe object.
global.describe.withMockDatabase = function (description, tests) {
if (typeof description === 'function') {
tests = description
description = 'with mock database'
}
describe(appendCount(description), () => {
withMockDatabase(tests)
})
}
global.describe.withTestDatabase = function (description, tests) {
if (typeof description === 'function') {
tests = description
description = 'with test database'
}
describe(appendCount(description), () => {
withTestDatabase(tests)
})
}
global.describe.withQuerySnapshots = function (description, tests) {
const caller = callsites()[1]
if (typeof description === 'function') {
tests = description
description = 'with query snapshots'
}
describe(appendCount(description), () => {
withQuerySnapshots(caller.getFileName(), tests)
})
}
export default db
/* eslint-env jest */
import User from './User'
const values = {
brian: {
username: 'brian',
email: 'brian@email.com',
password: 'my-password'
},
nopass: {
username: 'nopass',
email: 'nopass@email.com'
}
}
const models = {
brian: null,
nopass: null
}
describe.withQuerySnapshots(() => {
test('can insert users', () => {
return Promise.all([
User.query().insert(values.brian),
User.query().insert(values.nopass)
])
})
test('can fetch the user', () => {
return User
.query()
.where('username', '<>', 'admin')
.orderBy('username')
.then((res) => {
models.brian = res[0]
models.nopass = res[1]
expect(res.length).toBe(2)
expect(models.brian.email).toBe('brian@email.com')
expect(models.nopass.email).toBe('nopass@email.com')
expect(User.checkPassword('my-password', models.brian.auth)).toBe(true)
expect(User.checkPassword('wrong', models.brian.auth)).toBe(false)
})
})
test('can update a user', () => {
models.brian.role = 'admin'
return User
.query()
.updateAndFetchById(models.brian.id, models.brian)
.then((updated) => {
expect(updated.role).toBe('admin')
})
})
test('can patch a user', () => {
return User
.query()
.patchAndFetchById(models.nopass.id, {password: 'easy'})
.then((patched) => {
expect(User.checkPassword('easy', patched.auth)).toBe(true)
expect(User.checkPassword('wrong', patched.auth)).toBe(false)
})
})
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment