Skip to content

Instantly share code, notes, and snippets.

@Pyrolistical
Last active June 7, 2018 17:38
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Pyrolistical/74a6591394cde390b0be80f603363fcb to your computer and use it in GitHub Desktop.
Save Pyrolistical/74a6591394cde390b0be80f603363fcb to your computer and use it in GitHub Desktop.
ES6 module unit testing?
The pattern you showed at ployglotconf:
import a from './a'
import b from './b'
import c from './c'
export const xyz = (a, b, c) => {
return {
method() {
...use deps a, b, c
}
}
}
export default xyz(a, b, c)
This makes sense for unit testing xyz, now I can inject mock a, b, c.
But what doesn't make sense is how you handle dependencies that have a lifecycle, for example a database connection.
Let's say we are using mongodb, and using your pattern we create a db.js:
import {MongoClient} from 'mongodb'
import {mongoUri} from './config'
export const connect(mongoUri) => MongoClient.connect(mongoUri)
export default connect(mongoUri)
Now a consumer (repository.js) of db.js would look like:
import dbPromise from 'db'
export const repository = (dbPromise) => {
return {
async findUserByID(userID) {
const db = await dbPromise
... use db
}
}
}
export default repository(dbPromise)
Having to write const db = await dbPromise in every method is annoying, so we can change the export default to a function:
import dbPromise from 'mongodb'
export const repository = (db) => {
return {
async findUserByID(userID) {
... use db
}
}
}
export default (async () => repository(await dbPromise))()
But now the consumer of repository.js has a problem that the default export is a promise. This is getting out of hand.
How do you solve this?
We currently solve by writing everything in the top level entry point (usually server.js), this way we can do this:
import {MongoClient} from 'mongodb'
import Express from 'express'
import {mongoUri} from './config'
import Repository from './repository'
import Service from './service'
import Controller from './controller'
async function main() {
const db = await MongoClient.connect(mongoUri)
const repository = Repository(db)
const service = Service(repository)
const controller = Controller(service)
const application = Express()
application.use(controller)
application.listen...
}
main()
.catch(...)
Then our repository.js looks like:
export default (db) => {
return {
async findUserByID(userID) {
..use db
}
}
}
We can unit test repository.js by providing our own db.
But enough about our solution, how do you handle db in your solution?
@chrisnicola
Copy link

chrisnicola commented Jun 5, 2018

One thing I typically do to minimize repetition is to wrap the individual functions of my database, such as executing a query, starting or ending a transaction, streaming queries, etc, rather than just wrapping it's objects. I'll only wrap the ones I use as well, no need to provide the entire API of the db library if I'm not actually using it.

If I just wrap the connection to the database, then I still am leaking the abstraction of how that connection is designed in my own system. How will I abstract and test that? So what I probably have is:

import query from 'db';

function findUserById(query) {
  return {
    async execute(id) {
      await query('select email, name from users where user_id = :id', { id }) 
    }
  }
}

export default findUserById(query).execute;

So now my test for this is pretty trivial. If I had something more like this:

import db from 'db';

function findUserById(db) {
  return {
    async execute(id) {
      connection = await db.connect()
      await connection.query('select email, name from users where user_id = :id', { id }) 
    }
  }
}

export default findUserById(query).execute;

You can see how the test object I will need to create is much more complicated and so is the test required. We are also violating the constraint that we want all dependencies to just be functions and return functions, or plain data objects everywhere possible.

@Pyrolistical
Copy link
Author

Pyrolistical commented Jun 5, 2018

OK, I understand what you mean. Translating that to mongo would be:

db.js

import {MongoClient} from 'mongodb'
import {mongoUri} from './config'

export const connect(mongoUri) => MongoClient.connect(mongoUri)

export default connect(mongoUri)

query.js

import dbPromise from './db'

export async function find(collection, query, selector) {
  const db = await dbPromise
  return db.collection(collection).find(query, selector)
}

repository.js

import {find} from './query'

export function Repository(find) {
  return {
    findUserById(userId) {
      return find('user', userId)
    }
  }
}

export default Repository(find)

@chrisnicola
Copy link

chrisnicola commented Jun 7, 2018

Yes that is pretty close. I think query.js can be made a bit better. You'll find it is difficult to test as you can't substitute dbPromise. Wrapping find in a constructor that takes db as a parameter and is composed by default with dbPromise will solve that for you.

Also dbPromise is a more complex substitute because of the chaining it requires, so you'll end up needing to define a more complex mock, rather than just using a simple stub substitute. That might be unavoidable here though and by creating the find module you can isolate the problem to just one place simplifying your module composition and testing everywhere else.

But it is a good example of why methods on objects and chaining aren't a great idea from a testability perspective.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment