Organizing our Meteor code into modules can be a powerful improvement over the old globals-everywhere approach that many of us are used to.
Modules emphasize isolation. Everything within a module is scoped locally within that module, unless its explicitly exported to the outside world. This kind of isolation can lead to better readability and testability.
Unfortunately, many of the core features of the Meteor framework still rely on modifying global state. Defining things like methods, publications and template helpers all update the global state of the application.
This dichotomy can be confusing to Meteor developers new to the module system. How do we define methods in modules? Should we be exporting methods from modules? How do we import those methods into our application?
Importing Methods
When you call Meteor.methods(...)
, you’re modifying your application’s global state. The methods you pass into Meteor.methods
are pushed onto the global list of callable methods within your application.
Because it affects global state in this way, Meteor.methods
is said to have side effects.
Imagine that we have a module called paymentMethods.js
. The purpose of this module is to define several Meteor methods related to payment processing.
Inside of that module, we have a createPayment
method that lets logged in users create new payments based on some provided options. It looks something like this:
import { Meteor } from "meteor/meteor";
Meteor.methods({
createPayment(options) {
if (this.userId) {
...
}
else {
throw new Meteor.Error("unauthenticated");
}
}
});
You’ll notice that nothing is being exported from this module. This is because the call to Meteor.methods
is modifying the global state for us. We don’t need to export our methods to make them accessible outside of the module.
We could even define our methods locally and make them accessible globally using Meteor.methods
:
import { Meteor } from "meteor/meteor";
const methods = {
createPayment(options) {
...
}
};
Meteor.methods(methods);
We’ve moved our method definitions into a constant called methods
which is only accessible within our paymentMethods.js
module. However, our call to Meteor.methods
still pulls our methods into our global method map, making them accessible anywhere in our application.
While we don’t need to export our methods from our modules, we still need some piece of our application to import our paymentMethods.js
module. If our code never runs, our methods will never be defined and added to our list of global methods.
In our /server/main.js
file, we can import our paymentMethods.js
module. This will run our module’s code, defining the methods and passing them into Meteor.methods
:
import "/imports/paymentMethods.js";
Notice that we’re not importing anything from paymentMethods.js
. That’s because there is nothing to import. We just want this module to be executed.
Testing Methods
What if we wanted to unit test our paymentMethods.js
module?
it("creates a payment", function() {
let options = { type: "cc", ... };
// TODO: call createPayment?
expect(paymentId).to.be.ok;
});
How would we call createPayment
? The obvious answer would be to use Meteor.call
:
it("creates a payment", function() {
let options = { type: "cc", ... };
let paymentId = Meteor.call("createPayment", options);
expect(paymentId).to.be.ok;
});
But, our method is expecting a logged in user. This leads to all kinds of difficulties.
To successfully Meteor.call
our "createPayment"
method, we’ll have to either override Meteor.call
with a version that passes in a custom this
context that we can control, or go through the process of creating and logging in as an actual user before calling "createPayment"
.
Following these approaches, this kind of test arguably isn’t a unit test. It relies on the entire Meteor method and accounts infrastructures to execute.
A possible way around these problems is to try to access and call our method functions directly. On the server, Meteor.call
moves your method definitions into an internal object accessible through Meteor.server.method_handlers
.
In theory, we could call "createPayment"
directly through this object, and pass in a custom userId
:
it("creates a payment", function() {
let options = { type: "cc", ... };
let createPayment = Meteor.server.method_handlers.createPayment;
let paymentId = createPayment.bind({
userId: "1234567890"
})(options);
expect(paymentId).to.be.ok;
});
While this works, it’s a very fragile solution. Meteor.server.method_handlers
is an undocumented API and is subject to change. It’s dangerous to rely on this internal structure when there is no guarantee that it will be around in future versions of Meteor.
There has to be an better way to test our methods!
Exporting Methods
To solve this problem, let’s go back and revisit our paymentMethods.js
module:
import { Meteor } from "meteor/meteor";
const methods = {
createPayment(options) {
...
}
};
Meteor.methods(methods);
We can make the testing of this module vastly easier with one simple change:
import { Meteor } from "meteor/meteor";
export const methods = {
createPayment(options) {
...
}
};
Meteor.methods(methods);
By exporting the methods
object, we can now get a direct handle on our "createPayment"
method without having to dig through the Meteor internals. This makes our test much more straightforward:
import { methods } from "./paymentMethods.js";
it("creates a payment", function() {
let options = { type: "cc", ... };
let paymentId = methods.createPayment.bind({
userId: "1234567890"
})(options);
expect(paymentId).to.be.ok;
});
This is a clean test.
We’re directly calling our "createPayment"
method with a controllable this
context and input options
. This makes it very easy to test a variety of situations and scenarios that this method might encounter.
We’re not relying on any Meteor infrastructure or internal structures to run our tests; they’re completely independent of the platform.
Beyond Methods
What’s beautiful about this module-based approach is that it’s not limited to just Meteor methods. We could apply this same technique to creating and testing our publications and our template helpers, lifecycle callbacks and event handlers.
As a quick example, imagine defining a template in a module like this:
import { Template } from "meteor/templating";
export function onCreated() {
this.subscribe("foo");
...
};
export const helpers = {
foo: () => "bar",
....
};
export const events = {
"click .foo": function(e, t) {
...
}
};
Template.foo.onCreated(onCreated);
Template.foo.helpers(helpers);
Template.foo.events(event);
Because we’re defining our template’s helpers
, events
, and onCreated
callback as simple functions and objects, we can easily import them into a test module and test them directly:
import { events, helpers, onCreated} from "./foo.js";
describe("foo", function() {
it("sets things up when it's created", function() {
let subscriptions = [];
onCreated.bind({
subscribe(name) => subscriptions.push(name);
})();
expect(subscriptions).to.contain("foo");
});
it("returns bar from foo", function() {
let foo = helpers.foo();
expect(foo).to.equal("bar");
});
...
});
Our onCreated
test creates a custom version of this.subscribe
that pushes the subscription name onto an array, and after calling the onCreated
callback, asserts that "foo"
has been subscribed to.
The helpers.foo
test is less complex. It simply asserts that helpers.foo()
equals "bar"
.
Final Thoughts
Hopefully I’ve given you a clear picture of how I approach defining and testing my methods, publications, and template helpers in a post-1.3 world.
Modules are a very powerful tool that can make our lives as developers much easier if we take the time to explore and understand their full potential. Modularity makes code readability, testability, and re-usability significantly easier.
Happy modularizing!