-
-
Save Pyrolistical/74a6591394cde390b0be80f603363fcb to your computer and use it in GitHub Desktop.
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? |
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)
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.
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:
So now my test for this is pretty trivial. If I had something more like this:
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.