Skip to content

Instantly share code, notes, and snippets.

@mattapperson
Created June 29, 2019 20:52
Show Gist options
  • Save mattapperson/7b32d0260b3661c4c217285018212390 to your computer and use it in GitHub Desktop.
Save mattapperson/7b32d0260b3661c4c217285018212390 to your computer and use it in GitHub Desktop.

Automocking thoughts

Allow users to create mocks using 2 APIs... Consumers or providors could write mocks using these tools...

// This block only runs if recording live
await ifLiveBlock(async () => {
  await new Promise(resolve => {
    const pipe = spawn("mongod", ["--dbpath=<LOCATION>", "--port", "1223"]);
    pipe.stdout.on("data", function(data) {
      if (data.indexOf("started") !== -1) {
        resolve();
      }
    });
  });
});

// if CLI flag / env var is set to record, this returns the live server results
// if not, it returns a snapshotted version. When live the above ifLiveBlock would have
// started the server it would connect to making the process seamless
const results = await memorized("Mocked query for bar", async () => {
  const client = await mongodb.connect({ port: 1223 });
  return client.query({ foo: "bar" });
});

// In the above call to memorizeMock we have a name, a function that conditionaly called
// if we are "recording" or the snapshotted response if playing back.

Pros:

  • one test/mock file, and one step... No need to write a test that generates the mocks first, allowing it to feel a lot more like Jest snapshots.
  • This not only allows for tests to be run as unit tests... but if the CI flag is on also as intigration tests. All with a single test being written!
  • No setup to ensure is working localy or a CI... enviorment starting or not is not programaticly controlled in the test itself

That's the API... in practice it would look more like this:

///////////////////////
// user.test.js
///////////////////////
test("user creation works", () => {
  const user = require("../user");
  return user.create("mitchell", 22);
});

///////////////////////
// ./__mocks__/user.js
///////////////////////
const user = jest.genMockFromModule("../user");

// If the tests using this mock are live, this runs, otherwise it does not
await ifLiveBlock(async () => {
  await new Promise(resolve => {
    const pipe = spawn("mongod", ["--dbpath=<LOCATION>", "--port", "1223"]);
    pipe.stdout.on("data", function(data) {
      if (data.indexOf("started") !== -1) {
        resolve();
      }
    });
  });
});

// If live, this returns data from the live mongodb server,
// otherwise it returns from the memorized data saved to a snapshot
user.create = (name, age) => {
  return await memorized(`create-user`,
  async () => {
    const mongodb = require('mongodb');
    const client = await mongodb.connect({ port: 1223 });
    return await client.insert({ name, age });
  }, [...args]
  // ˆˆ matches all args to the snapshot data
);
};

module.exports = user;

If memorized/ifLiveBlock use an env var to toggle live vs mocked data, then this becomes Test framework agnostic.

This API/setup also means I can make use of all the advantages of this regardless of if I am the providor OR the consumer of the mocks, meaning I don't need to get buy-in from the entire team or company to use it.

@mattapperson
Copy link
Author

More or less, you have it. Your genMockFromModule call is incorrect, it needs to be called on ./user

Also your code is not doing the more powerful step of starting the dB process using ifLiveBlock. Being able to do so is what allows a single test to be both unit and integration depending on if it’s run love or not

@shlomokraus
Copy link

I just wrapped the db connection into mongoClient.init(), so it supposed to be the same

@mattapperson
Copy link
Author

Yes, in my code we start the server process, in yours the DB would need to be manually started?

The mental model I have is more or less...

  1. Create a "mock" that is 100% working with live data... so... not a mock 😊
  2. Then wrap said mock in memorize, this saves the result as a "snapshot"
  3. Run the test in record mode... this is more or less an integration test, as it connects to a real DB, and generates real results... it also saves these results as a snapshot
  4. run the test again in regular mode... now the test is a unit test, returning the recorded snapshots.

ifLiveBlock is a helper method used so that I dont have to boot up other systems I need to integrate with manually, it's all self contained in the test, all that is needed to get live data. Not all users would need this.

Your example is simpler and I think perhaps works too, but is "abusing" ifLiveBlock to run the connection code... this would work, but now your importing/requiring the mongo module when running as a unit or as an integration test... this would have perf implications, but other then that it does work, yes.

@shlomokraus
Copy link

shlomokraus commented Jun 30, 2019

The ifLiveBlock is something that works exactly the same. I will use it for initiation logic and it is a good idea for many other cases.

So if I translate it into Mockshot, it will be almost the same.

// user.test.ts

// The test that creates\validate the mock 

test("user create", async () => {
  const result = user.create("Some One", 21);  
  // Regular assertions checking values
  expect(result.name).toEqual("Some One");
  expect(result.age).toEqual(21);
  // Assertion checking shape 
  expect(result).toMatchMock(UserService, "create", "success"); 
  // ^ This part creates the mock and also asserts against it. Notice that I've added 
  // a mock name "success", this is because every method usually have different types of mock results. 
  // You may also have a mock for when user doesn't exists or if it failed.
});

Now Mockshot will use AST to generate an actual class and create mocks/UserService.ts that looks something like this:

export class UserServiceMock {
    static create(mock: "success"): any {
        switch (mock) {
            case "success":
                return {
                  "name": "Some One",
                  "age": 18
                 }
            default:
                throw Error("Unknown mock: "+mock);
        }
    }
}

That means, that I can take this and simply merge it with the real class using isLiveBlock.
There are actually many ways to do it cleanly, it is easy to do automatically and pretty cool:

// set user service mock as default
let userService = new UserServiceMock();

// override the mock with live instance if needed
ifLiveBlock(()=>{
  mongoClient.init(); // connect to db etc...
  userService = new UserService(mongoClient);
});

console.log(userService);
// *** Now we have "userService" which is either a mock or the actual instance with all the initiation logic ran. 

@shlomokraus
Copy link

Notice that I didn't add any overhead to what someone else would do.
There is one test for "UserService.create", and another test that uses it.
The only overhead is adding "toMatchMock" on the original test. That is enough to generate
and validate the mock.
toMatchMock is just like toMatchSnapshot, only that it saves the snapshot in a way that allows it
to be deserialized easily into a class structure.

So the hard part of generating, validating and syncing the mock is done. Now you can wrap and use it any way you want, like with the isLiveBlock.

That said, I use IoC containers and usually have a single point of initialization logic in the test, so it is
easy to wrap that point in isLiveBlock, which is something that I actually would implement now :)

@mattapperson
Copy link
Author

Yes, but this would still require one of the following:

  1. enough buy-in from the provider to use toMatchMock.
  2. write my own test that uses toMatchMock.

Or am I missing something?

If I am not missing something, this is a major issue.

Based on your blogs I think this is both correct and an intended “feature”... it just does not work well for all use-cases... including mine.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment