0.0.10 • Published 10 years ago

metafunction v0.0.10

Weekly downloads
12
License
MIT
Repository
github
Last release
10 years ago

metafunction

Build Status

This is a testing library that decorates JavaScript's Function.prototype in to provide capabilities for reflection and mocking to address the following:

  • how can we inspect items defined in closures?
  • how can we override (or mock) them?

Following an exchange with Phil Walton (@philwalton) (see Mocking - not testing - private functions), I developed this idea while working on vm-shim from which this repo borrows some internal methods.

The main trick uses Function.prototype.toString() to get a function descriptor (arguments, name, returns, source), and provide some methods for overwriting symbols and references within the function source and executing the new source in a new context.

install

npm install metafunction

git clone https://github.com/dfkaye/metafunction.git

use

  • in node.js:

    require('metafunction')

  • in browser, metafunction is defined on the global scope

    <script src="../metafunction.js"></script>

Those will give you...

Function.prototype.meta(alias?)

Any function or method now has a .meta method:

var meta = someFunction.meta();

The alias argument is optional but really should be used any time you want to run a new invocation (much like naming your test iterations).

The object returned by this call is actually a function. The returned meta function contains a descriptor object containing the parts of the original function definition (arguments, name, returns, source).

It also contains methods for the aliasing, mocking/overriding and inspection of closures and closure references, described below.

complete example spec

Best is first to view a fairly complete example:

describe('README example', function () {

  // fixture
  var fn = (function () {
    var closure = true;
    var inner = function fn(exampleArg) {
      // I'm a closure inside by an IIFE
      return closure;
    };
    return inner;
  }());
  
  var meta;
  
  beforeEach(function () {
    meta = fn.meta();
  });
  
  it ('descriptor data', function () {
  
    var descriptor = meta.descriptor;
    
    expect(descriptor.source).toBe(fn.toString());
    expect(descriptor.arguments[0]).toBe('exampleArg');
    expect(descriptor.name).toBe('fn');
    expect(descriptor.source).toContain('// I\'m a closure inside by an' +
                                        ' IIFE');
    expect(descriptor.source).toContain('return closure');
    expect(descriptor.returns.length).toBe(1);
    expect(descriptor.returns[0]).toBe('closure');
  });
  
  it ('invoke with context', function () {
    meta.invoke(function () {
    
      // should pass, calling alias()
      expect(fn()).toBe('mocked');
      
      // should see context object and its properties
      expect(context).toBeDefined();
      
      // should see context object and its properties
      expect(context.closure).toBe('mocked');
      
      // should see context object and its properties
      expect(context.expect).toBe(expect);
      
    }, { expect: expect, closure: 'mocked' });
  });
  
  it ('alias, inject, invoke', function () {
  
    meta('alias'); // alias is used by invocation
    meta.inject('closure', 'mockClosure');
    meta.invoke(function () {
    
      // should pass, calling alias()
      expect(alias()).toBe('mocked');
      
      // should see context object and its properties
      expect(context.mockClosure).toBe('mocked');
      
    }, { expect: expect, mockClosure: 'mocked' });
  });
  
  it('chained API example', function () {
  
    meta('chain').inject('closure', 'mockClosure').invoke(function () {
    
      expect(chain()).toBe('mocked'); // should pass, calling chain()
      
    }, { expect: expect, mockClosure: 'mocked' });
  });
  
  it('lisped API example with 2-arg', function () {
  
    (meta('lisp')
    ('closure', 'mockedClosure')
    (function () {

      expect(lisp()).toBe('mocked'); // should pass, calling lisp()
      
     }, { 
      expect: expect, mockedClosure: 'mocked' 
     }));
  });

  it('alternate lisped API example', function () {
    (meta('lisp')
    ({ expect: expect, closure: 'mocked' })
    (function () {
      expect(lisp()).toBe('mocked'); // should pass, calling lisp()
    }));
  });
  
  it('really terse alternate lisped API example', function () {
     // surround function object entirely
    (meta) 
     // pass alias arg to it
    ('lisp')
     // set context
    ({ expect: expect, closure: 'mocked' })
     // set invoke function which should pass, calling lisp()
    (function () {
      expect(lisp()).toBe('mocked'); 
    });
     // final semi-colon, only one parenthesis after final call
  });
  
  it('nested invocation example', function () {

    var ctx = { expect: expect, meta: meta, closure: 'mocked' };    
    
    meta('main').invoke(function() {
      
      expect(main()).toBe('mocked');
      
      // context argument to invoke() is visible in function scope
      context.closure = 'nested';
      
      meta('nestedMain').invoke(function() {
        expect(nestedMain()).toBe('nested');
      }, context);
      
    }, ctx);
  });
});

Instead of figuring out the internal value inside the closure, we only override the reference to the variable holding onto it, first with inject which takes the target name and the mocking name, then with invoke with takes a function inside of which we can call the alias of the closure, and a context argument (a la vm.runInContext) which contains references to our test library's expect method (I'm using jasmine for this repo), a value to be set for the the injected name 'mockInside.' You also have access to the context inside the function executed by invoke.

scope of invoke()

To prevent scope leaking, in any invoke(fn, ctx) call, the function fn argument's scope (this) is set to the context argument. Thus the following bdd test should pass:

meta('thisTest').invoke(function() {

  expect(this).toBe(context);
  
}, { expect: expect });

global === window

Where node.js has a named global object, browser engines typically do not. The window object contains a global reference to itself so you can use global in both environments.

method chaining

The meta() API supports chaining the individual methods together:

meta('chain').
inject('closure', 'mockClosure').
invoke(function () {
  expect(chain()).toBe('mocked') // should pass, calling chain()
}, { expect: expect, mockClosure: 'mocked' })

method lisping

Wait what? If you're like @HipsterHacker, you can use the experimental 'lisped' API, based on this idea:

(meta('lisp')
  ('closure', 'mockClosure')
  (function () {
    expect(lisp()).toBe('mocked') // should pass, calling lisp()
   }, { 
      expect: expect, mockClosure: 'mocked' 
   }));
  

Here's an evolving alternate form that uses a configuration specifier as the invocation context and avoids the inject re-naming step:

(meta('lisp')
  ({ expect: expect, closure: 'mocked' })
  (function () {
    expect(lisp()).toBe('mocked')
  }));

Another wiggier form:

(meta)
('lisp')
({ expect: expect, closure: 'mocked' })
(function () {
  expect(lisp()).toBe('mocked')
});

TIP: Expect this form of invocation sequence to show up elsewhere.

nested invocations

More proof-of-concept gold-plating, to call invocations inside other invocations you can pass the meta function object to the context, update the context as needed, then call invoke again:

var ctx = { expect: expect, meta: meta, closure: 'mocked' };    

meta('main').invoke(function() {
  expect(main()).toBe('mocked')
  
  // context argument to invoke() is visible in function scope
  context.closure = 'nested'
  
  meta('nestedMain').invoke(function() {
    expect(nestedMain()).toBe('nested')
  }, context)
  
}, ctx)

This is just a proof test that we're not blowing up the call stack in certain environments (namely, Internet Explorer).

extracting private functions directly

I've added an extract(functionName) method that can be used for excavating private functions from within an IFFE. This requires that you name the IFFE itself, then attach it to the exported return value ('main' in this case):

var fn = (function iffe(){

  // private
  function increment(n) {
    return n + 1;
  }
  
  // public
  function main(n) {
    return increment(n);
  };
  
  // expose the iffe - could make this conditional by environment/flag
  main.iffe = iffe;
  
  // make return statement separate from definition
  return main;
}());

Given that the iffe is now reachable as fn.iffe you can meta-fy it, then use extract('increment') to get a copy of the increment function, then use invoke() to exercise it with the context param:

it('should extract increment() function and invoke it', function(){

  var meta = fn.iffe.meta();
  var increment = meta.extract('increment');
  
  expect(typeof increment).toBe('function');
  
  for (var i = 0; i < 10; i++) {
  
    meta.invoke(function() {
    
      expect(increment(i)).toBe(i + 1);
      
    }, { increment: increment, i : i });
  }
});
  
  

tests

The complete test spec is contained in https://github.com/dfkaye/metafunction/blob/master/test/meta.spec.js.

node tests

Using Misko Hevery's jasmine-node to run command line tests on node (even though this project initially aimed at a browser shim).

npm test
# => jasmine-node --verbose ./test/node.spec.js

browser tests

Using @pivotallabs' jasmine-2.0.0 for the browser suite.

The jasmine2 browser test page is viewable on rawgit.

testem

Using Toby Ho's MAGNIFICENT testemjs to drive tests in multiple browsers for jasmine-2.0.0 (see how to hack testem for jasmine 2), as well as jasmine-node. The testem.json file uses the standalone test page above, and also uses a custom launcher for jasmine-node (v 1.3.1).

View both test types at the console by running:

testem -l j
0.0.10

10 years ago

0.0.9

10 years ago

0.0.8

10 years ago

0.0.7

10 years ago

0.0.6

10 years ago

0.0.5

10 years ago

0.0.4

10 years ago

0.0.3

10 years ago

0.0.2

10 years ago

0.0.1

10 years ago