Reputation: 26086
I have a model mock that I want to reuse like so:
// simplified
class ModelMock {
static async findOneAndUpdate() {
}
static async findOne() {
}
async save() {
}
}
but need to mock them individually per the model such as
const models = {
User: ModelMock,
Business: ModelMock
}
but I really want each mock class to be its own thing without having to resort to prototypal syntax or duplicating code.
The reason being in testing...
sinon.mock(MockModule.prototype).expects('save').resolves({ specific: 'thing' })
won't work as I have to have a specific class for each model then and also the static methods are shared of course.
Notice both static and instance methods
How do I do that?
Upvotes: 4
Views: 1056
Reputation: 3087
This answer is intentionally a detailed "I don't think you can", in an effort to help others who are trying to do the same thing understand why I concluded that it's not possible, at least not without eval()
and maybe not even then. I have a fairly deep understanding of Javascript's prototypical class implementation, but there are many who have deeper knowledge than me.
I came here with the question stated in this post, and also ended up settling for a class factory. My requirement was that the cloned class is identical to one obtained with a class factory. Specifically, I want a function such that this:
class Parent {}
class Child extends Parent {}
const Sibling = cloneClass(Child)
results in the exact same state as this:
class Parent {}
function classFactory() {
return class Child extends Parent {}
}
const Child = classFactory()
const Sibling = classFactory()
Things that are thus dealbreakers for my purposes:
Sibling
that is an instance of or prototypically descended from Child
(its first prototype should be Parent
)Sibling
that shares a prototype or function definitions with Child
For my needs, it is useful if Sibling.name == Child.name == 'Child'
, which is the case in the class-factory setup.
Technically to be exactly the same there are more requirements (Sibling
's methods can't be prototypically descended from Child
's methods, for instance), but I think that is moot, for reasons that will soon become apparent.
The thing that makes this impossible by my reckoning is actually quite simple: it requires cloning functions, and you can't do that. There are a couple questions about it here, which have various solutions that work in some cases but are not actually cloning, and don't fulfill my requirements. For what it's worth, functions are on lodash's list of "uncloneable values" as well.
This is relevant for class methods, but more crucially, it is relevant because under the hood, classes are functions. In a very literal sense, a class is its constructor function. Thus, even if you could deal with class methods that are prototypically descended from Child
's methods, I think you can't get around the fact that that also means that the Sibling
class itself - which is a function - will also have to be prototypically descended from Child
.
Edit: See below, if Child
doesn't have a constructor function, then you may be able to skirt around this requirement.
I'm happy to be proven wrong here, or have my understanding corrected, but I think that is the central blocker here: classes are functions, and you can't clone a function.
One route I haven't pursued, because it is darker magic than I am willing to delve into, is using the Function()
constructor to almost-but-not-quite-eval yourself into a truly "cloned" function. I am not sure if this is possible, and I am not knowledgeable enough of the implications of doing so to try.
If you want to have a go at it, I made a snippet that has a few tests that assert my requirements - if you can get my requirements passing with a clone function, do let me know!
Edit: @Bergi offered up a solution that I've included in the snippet below. It does seem to do the job! I believe it gets around the problem of cloning the constructor by...not doing so, since in my case the child classes don't have their own constructor. Thus an empty function (with everything else tacked on) is indeed equivalent to a clone. It also comes with all the standard disclaimers around using setPrototype.
'use strict'
let hadWarning = false
const getProto = Object.getPrototypeOf
class Parent {}
function classFactory () {
return class Child extends Parent {}
}
function factoryTest () {
const Child = classFactory()
const Sibling = classFactory()
runTest(Child, Sibling, 'classFactory')
}
/* Adapted from @Bergi */
function cloneClass (Target, Source) {
return Object.defineProperties(
Object.setPrototypeOf(
Target,
Object.getPrototypeOf(Source)
),
{
...Object.getOwnPropertyDescriptors(Source),
prototype: {
value: Object.create(
Object.getPrototypeOf(Source.prototype),
Object.getOwnPropertyDescriptors(Source.prototype)
)
}
}
)
}
function berghiTest () {
class Child extends Parent {}
const Sibling = cloneClass(function Sibling () {}, Child)
runTest(Child, Sibling, 'Bergi\'s clone')
}
factoryTest()
berghiTest()
/* Assertion support */
function fail (message, warn) {
if (warn) {
hadWarning = true
console.warn(`Warning: ${message}`)
} else {
const stack = new Error().stack.split('\n')
throw new Error(`${message} ${stack[3].trim()}`)
}
}
function assertEqual (expected, actual, warn) {
if (expected !== actual) {
fail(`Expected ${actual} to equal ${expected}`, warn)
}
}
function assertNotEqual (expected, actual, warn) {
if (expected === actual) {
fail(`Expected ${actual} to not equal ${expected}`, warn)
}
}
function runTest (Child, Sibling, testName) {
Child.classTag = 'Child'
Sibling.classTag = 'Sibling'
hadWarning = false
assertEqual(Child.name, 'Child')
assertEqual(Sibling.name, Child.name, true) // Maybe not a hard requirement, but nice
assertEqual(Child.classTag, 'Child')
assertEqual(Sibling.classTag, 'Sibling')
assertEqual(getProto(Child).name, 'Parent')
assertEqual(getProto(Sibling).name, 'Parent')
assertEqual(getProto(Child), Parent)
assertEqual(getProto(Sibling), Parent)
assertNotEqual(Child.prototype, Sibling.prototype)
assertEqual(getProto(Child.prototype), Parent.prototype)
assertEqual(getProto(Sibling.prototype), Parent.prototype)
const child = new Child()
const sibling = new Sibling()
assertEqual(sibling instanceof Child, false)
assertEqual(child instanceof Parent, true)
assertEqual(sibling instanceof Parent, true)
if (hadWarning) {
console.log(`${testName} passed (with warnings)`)
} else {
console.log(`${testName} passed!`)
}
}
Upvotes: 1
Reputation: 26086
I resorted to doing a class factory like so:
function getModelMock() {
return class {
static async findOneAndUpdate() {
}
static async findOne() {
}
async save() {
}
}
}
as you can use like so:
const models = {
Business: getModelMock(),
User: getModelMock()
}
sinon.mock(models.Business.prototype).expects('save').resolves({ _id: 'businessId' })
sinon.mock(models.Business).expects('findOne').resolves({ _id: 'businessId' })
sinon.mock(models.User.prototype).expects('save').resolves({ _id: 'userId' })
as you can anonymously make a class without stating its name which I thought was interesting but are there better ways to do this with an actual clone if you cannot make a factory?
Upvotes: 2
Reputation: 44105
If you don't want to use a factory function, you could use new
:
const models = {
User: new ModelMock(),
Business: new ModelMock()
};
Upvotes: 0