Often in a JS project we import a 3rd party library in order to provide a solution to a problem we have encountered, however there are cases where a library does not provide the full specific functionality that we have to achieve when working on a project.
In production scenarios, we will include libraries for testing and database tools such as puppeteer, and mongoose.
This is the most simplest solution out there to achieve and we will take mongoose for an example case study.
Every time we create a mongoose model instance, we have access to a bunch of methods, most noticeable the find method.
const blogs = await Blog.find({ _user: req.user.id });
This is a standard mongoose query which is 100% good, but what happens when someone down the line tells us to cache the query.
Well... There is a new problem, and that is mongoose does not provide us with a solution to do the following.
const blogs = await Blog.find({ _user: req.user.id }).cache();
As you can see, the cache
method is not part of mongoose, however being able to take this approach is something that a developer would call, the ideal solution.
The idea behind this solution is based on our knowledge on mongoose or the library we are using in our project.
Anyone that knows mongoose will know the following quirk when using mongoose.
query.exec((err, result) => console.log(result));
// Same as...
query.then(result => console.log(result));
// Same as...
const result = await query;
So the most important thing to notice is that when we use either await
or then
method call, we are always calling the exec
method. So what if we then modified the mogoose exec
method to be something useful for us, in this case include our caching code.
services/cache.js
- The purpose of this file would be to import mongoose and directly override and add the methods we want.
- We modify
exec
to add our caching solution - We add
cache
in order to give the developer a clean solution to chaincache
to the their function calls
const mongoose = require('mongoose');
const redis = require('redis');
const util = require('util');
const redisUrl = 'redis://127.0.0.1:6379';
const client = redis.createClient(redisUrl);
client.hget = util.promisify(client.hget);
const exec = mongoose.Query.prototype.exec;
mongoose.Query.prototype.cache = function(options = {}) {
this.useCache = true;
this.hashKey = JSON.stringify(options.key || '');
return this;
};
mongoose.Query.prototype.exec = async function() {
if (!this.useCache) {
return exec.apply(this, arguments);
}
const key = JSON.stringify(
Object.assign({}, this.getQuery(), {
collection: this.mongooseCollection.name
})
);
// See if we have a value for 'key' in redis
const cacheValue = await client.hget(this.hashKey, key);
// If we do, return that
if (cacheValue) {
const doc = JSON.parse(cacheValue);
return Array.isArray(doc)
? doc.map(d => new this.model(d))
: new this.model(doc);
}
// Otherwise, issue the query and store the result in redis
const result = await exec.apply(this, arguments);
client.hset(this.hashKey, key, JSON.stringify(result), 'EX', 10);
return result;
};
module.exports = {
clearHash(hashKey) {
client.del(JSON.stringify(hashKey));
}
};
Where we setup mongoose, in my case anywhere after I import mongoose in my index.js
, we just make sure to import our new code as such.
require('./services/cache');
Boom!!! This will let the developer access our custom method anywhere where we use mongoose.
const blogs = await Blog.find({ _user: req.user.id }).cache();
When it comes to testing, we may include libraries such as puppeteer to help us for end to end testing. There are many cases when we write test code, that we are just writing a lot of boilerplate, in this case we will use the code for login as an example.
await page.setCookie({ name: 'session', value: session });
await page.setCookie({ name: 'session.sig', value: sig });
await page.goto('localhost:3000');
await page.waitFor('a[href="/auth/logout"]');
We would prefer to write the following code and have the above executed whe login is called.
await page.login();
PS. If you are wondering what is page, here is the code. It is an object for our webpage.
const browser = await puppeteer.launch({
headless: false
});
page = await browser.newPage();
Extending a library can be problamatic when we cannot tell the library to use our extended class. In this case, puppeteer will have no idea about our new class that exists and how to use it.
// Third party library
class Page {
goto() { return 'I go to page' }
setCookie() { return 'I will set a cookie' }
}
class CustomPage extends Page {
login() { return 'I am going to login. Put all the code to login here'; }
}
So if we can't extend the class because we don't have full control, then how about wrap our instance CustomPage
by passing an instance of Page
to the contructor.
// Third party library
class Page {
goto() { return 'I go to page' }
setCookie() { return 'I will set a cookie' }
}
class CustomPage {
constructor(page) {
this.page = page;
}
login() { return 'I am going to login. Put all the code to login here'; }
}
Hmm... So I now have to write the following in my test cases.
const page = new Page();
const customPage = new CustomPage(page);
customPage.login();
customPage.page.goto();
As you can see, whenever I want to access a method belonging to Page, aka a Puppeteer method, I will always have to type customPage.page.setCookie
instead of customPage.setCookie
.
The extra code is really not ideal for a lazy person.
A solution we could use are proxies. The below code is an example on how proxies can be incredibly powerful to just access methods from a different class.
// Third party library
class Greetings {
english() { return 'Hello' }
spanish() { return 'Hola' }
}
class MoreGreetings {
german() { return 'Hallo'; }
french() { return 'Bonjour' }
}
const greetings = new Greetings();
const moreGreetings = new MoreGreetings();
const allGreetings = new Proxy(moreGreetings, {
get: function(target, property) {
return target[property] || greetings[property];
}
});
// Outputs Greetings method return value
console.log(allGreetings.english());
Therefore in Puppeteer's case we would like to do the following.
// Third party library
class Page {
goto() { return 'I go to page' }
setCookie() { return 'I will set a cookie' }
}
class CustomPage {
constructor(page) {
this.page = page;
}
login() { return 'I am going to login. Put all the code to login here'; }
}
const page = new Page();
const customPage = new CustomPage(page);
const superPage = new Proxy(customPage, {
get: function(target, property) {
return target[property] || page[property];
}
});
And...
Boom!!!
customPage.login();
customPage.page.goto();
Can be the following.
superPage.login();
superPage.goto();
Notice how I don't need to write .page
all the time. I also left the reference there for the page, because we need it as seen later in the final version of the solution.
We can take this approach one further, as we always create a Page
and CustomPage
for all the tests we run we can therefore do the following and create a method that returns our SuperPage
instance.
const buildPage = () => {
const page = new Page();
const customPage = new CustomPage(page);
const superPage = new Proxy(customPage, {
get: function(target, property) {
return target[property] || page[property];
}
});
return superPage;
}
So anytime we want to create a new page, we can call buildPage
in our beforeEach
statements.
buildPage();
However the whole idea behind CustomPage class is to get involved with the proxy solution, therefore wouldn't it make sense to have the buildPage
logic inside of CustomPage
?
The answer is YES!
So the pattern that comes to mind is get buildPage
logic and put it in a static
method called build
inside of CustomPage
.
// Third party library
class Page {
goto() { return 'I go to page' }
setCookie() { return 'I will set a cookie' }
}
class CustomPage {
static build() {
const page = new Page();
const customPage = new CustomPage(page);
const superPage = new Proxy(customPage, {
get: function(target, property) {
return target[property] || page[property];
}
});
return superPage;
}
constructor(page) {
this.page = page;
}
login() { return 'I am going to login. Put all the code to login here'; }
}
Now when we execute our beforeEach
statements or anywhere where we need a SuperPage
instance, we can just call the static method.
const superPage = CustomPage.build();
superPage.login(); // The custom method we created
superPage.goto(); // Method from puppeteer
superPage.setCookie(); // Method from puppeteer
If you remember the first paragraph, the idea behind this appropach was moving the boilerplate code into a manageable location. Now with the power of proxies we will have the following code.
helpers/Page.js
const puppeteer = require('puppeteer');
const sessionFactory = require('../factories/sessionFactory');
const userFactory = require('../factories/userFactory');
class CustomPage {
static async build() {
const browser = await puppeteer.launch({
headless: false
});
const page = await browser.newPage();
const customPage = new CustomPage(page);
return new Proxy(customPage, {
get: function(target, property) {
return customPage[property] || browser[property] || page[property];
}
});
}
constructor(page) {
this.page = page;
}
async login() {
const user = await userFactory();
const { session, sig } = sessionFactory(user);
await this.page.setCookie({ name: 'session', value: session });
await this.page.setCookie({ name: 'session.sig', value: sig });
await this.page.goto('localhost:3000');
await this.page.waitFor('a[href="/auth/logout"]');
}
}
module.exports = CustomPage;
header.test.js
const Page = require('./helpers/Page');
let page;
beforeEach(async () => {
page = await Page.build();
await page.goto('localhost:3000');
});
afterEach(async () => {
await page.close();
});
test('when signed in, shows logout button', async () => {
await page.login();
const text = await page.$eval('a[href="/auth/logout"]', el => el.innerHTML);
expect(text).toEqual('Logout');
});