Repository: Yoctol/bottender
Pull request #280 (2018-06-06 07:19:05)

add TestBot for file testing and snapshot testing
- cli

```
bottender test inputFile --out-file outputFile --options
```

- tests data

```js
const tests = [
  'hello',
  'world',

   // or payload?
   '/payload <PAYLOAD>'
];
```

- snapshot testing

```js
const bot = new TestBot();

bot.onEvent(handler);

const result = await bot.runTests(tests);

expect(result).toMatchSnapshot();
```




Patch:
diff --git a/package.json b/package.json
index 19188d15..aa4f948f 100644
--- a/package.json
+++ b/package.json
@@ -160,7 +160,9 @@
     ],
     "testPathIgnorePatterns": [
       "/node_modules/",
-      "/examples/"
+      "/examples/",
+      "lib/cli/providers/sh/test.js",
+      "src/cli/providers/sh/test.js"
     ],
     "reporters": [
       "default",
diff --git a/src/browser.js b/src/browser.js
index 6e2ebebf..c1a2b3df 100644
--- a/src/browser.js
+++ b/src/browser.js
@@ -2,9 +2,11 @@
 
 /* Bot */
 export { default as Bot } from './bot/Bot';
+export { default as TestBot } from './bot/TestBot';
 
 /* Connector */
 export { default as Connector } from './bot/Connector';
+export { default as TestConnector } from './bot/TestConnector';
 
 /* HandlerBuilder */
 export { default as middleware } from './handlers/middleware';
@@ -28,6 +30,7 @@ export { default as ClassifierHandler } from './handlers/ClassifierHandler';
 
 /* Context */
 export { default as Context } from './context/Context';
+export { default as TestContext } from './context/TestContext';
 
 /* Plugins */
 export { default as withTyping } from './plugins/withTyping';
diff --git a/src/cli/providers/sh/index.js b/src/cli/providers/sh/index.js
index 9c5a09bb..80ea62bd 100644
--- a/src/cli/providers/sh/index.js
+++ b/src/cli/providers/sh/index.js
@@ -1,9 +1,12 @@
 export default {
   title: 'Bottender',
-  subcommands: new Set(['help', 'init']),
+  subcommands: new Set(['help', 'init', 'test']),
   get init() {
     return require('./init').default;
   },
+  get test() {
+    return require('./test').default;
+  },
   get help() {
     return require('./help').default;
   },
diff --git a/src/cli/shared/loadModule.js b/src/cli/shared/loadModule.js
new file mode 100644
index 00000000..affcc3a0
--- /dev/null
+++ b/src/cli/shared/loadModule.js
@@ -0,0 +1,16 @@
+import path from 'path';
+
+export default function loadModule(modulePath) {
+  let mod;
+  try {
+    mod = require(path.resolve(modulePath)); // eslint-disable-line import/no-dynamic-require
+  } catch (err) {} // eslint-disable-line
+
+  if (!mod) return null;
+
+  if (typeof mod === 'object' && mod.default) {
+    return mod.default;
+  }
+
+  return mod;
+}
diff --git a/src/index.js b/src/index.js
index f6acef38..a1679d4a 100644
--- a/src/index.js
+++ b/src/index.js
@@ -5,6 +5,7 @@ import * as utils from './utils';
 /* Bot */
 export { default as Bot } from './bot/Bot';
 export { default as ConsoleBot } from './bot/ConsoleBot';
+export { default as TestBot } from './bot/TestBot';
 export { default as MessengerBot } from './bot/MessengerBot';
 export { default as LineBot } from './bot/LineBot';
 export { default as SlackBot } from './bot/SlackBot';
@@ -14,6 +15,7 @@ export { default as ViberBot } from './bot/ViberBot';
 /* Connector */
 export { default as Connector } from './bot/Connector';
 export { default as ConsoleConnector } from './bot/ConsoleConnector';
+export { default as TestConnector } from './bot/TestConnector';
 export { default as MessengerConnector } from './bot/MessengerConnector';
 export { default as LineConnector } from './bot/LineConnector';
 export { default as SlackConnector } from './bot/SlackConnector';
@@ -55,6 +57,7 @@ export { default as withTyping } from './plugins/withTyping';
 /* Context */
 export { default as Context } from './context/Context';
 export { default as ConsoleContext } from './context/ConsoleContext';
+export { default as TestContext } from './context/TestContext';
 export { default as MessengerContext } from './context/MessengerContext';
 export { default as LineContext } from './context/LineContext';
 export { default as SlackContext } from './context/SlackContext';
@@ -63,6 +66,7 @@ export { default as ViberContext } from './context/ViberContext';
 
 /* Event */
 export { default as ConsoleEvent } from './context/ConsoleEvent';
+export { default as TestEvent } from './context/TestEvent';
 export { default as MessengerEvent } from './context/MessengerEvent';
 export { default as LineEvent } from './context/LineEvent';
 export { default as SlackEvent } from './context/SlackEvent';


Test patch:
diff --git a/src/__tests__/browser.spec.js b/src/__tests__/browser.spec.js
index 75b32a77..e145eb74 100644
--- a/src/__tests__/browser.spec.js
+++ b/src/__tests__/browser.spec.js
@@ -3,10 +3,12 @@ import * as core from '../browser';
 describe('browser', () => {
   it('export bots', () => {
     expect(core.Bot).toBeDefined();
+    expect(core.TestBot).toBeDefined();
   });
 
   it('export connectors', () => {
     expect(core.Connector).toBeDefined();
+    expect(core.TestConnector).toBeDefined();
   });
 
   it('export cache implements', () => {
@@ -26,6 +28,7 @@ describe('browser', () => {
 
   it('export contexts', () => {
     expect(core.Context).toBeDefined();
+    expect(core.TestContext).toBeDefined();
   });
 
   it('export extensions', () => {
diff --git a/src/__tests__/index.spec.js b/src/__tests__/index.spec.js
index 16f59867..d2f58647 100644
--- a/src/__tests__/index.spec.js
+++ b/src/__tests__/index.spec.js
@@ -4,6 +4,7 @@ describe('core', () => {
   it('export bots', () => {
     expect(core.Bot).toBeDefined();
     expect(core.ConsoleBot).toBeDefined();
+    expect(core.TestBot).toBeDefined();
     expect(core.MessengerBot).toBeDefined();
     expect(core.LineBot).toBeDefined();
     expect(core.SlackBot).toBeDefined();
@@ -14,6 +15,7 @@ describe('core', () => {
   it('export connectors', () => {
     expect(core.Connector).toBeDefined();
     expect(core.ConsoleConnector).toBeDefined();
+    expect(core.TestConnector).toBeDefined();
     expect(core.MessengerConnector).toBeDefined();
     expect(core.LineConnector).toBeDefined();
     expect(core.SlackConnector).toBeDefined();
@@ -47,6 +49,13 @@ describe('core', () => {
 
   it('export contexts', () => {
     expect(core.Context).toBeDefined();
+    expect(core.ConsoleContext).toBeDefined();
+    expect(core.TestContext).toBeDefined();
+    expect(core.MessengerContext).toBeDefined();
+    expect(core.LineContext).toBeDefined();
+    expect(core.SlackContext).toBeDefined();
+    expect(core.TelegramContext).toBeDefined();
+    expect(core.ViberContext).toBeDefined();
   });
 
   it('export extensions', () => {
diff --git a/src/bot/TestBot.js b/src/bot/TestBot.js
new file mode 100644
index 00000000..016869f5
--- /dev/null
+++ b/src/bot/TestBot.js
@@ -0,0 +1,73 @@
+/* @flow */
+import warning from 'warning';
+
+import type { SessionStore } from '../session/SessionStore';
+
+import Bot from './Bot';
+import TestConnector from './TestConnector';
+
+export default class TestBot extends Bot {
+  constructor({
+    sessionStore,
+    fallbackMethods,
+  }: { sessionStore: SessionStore, fallbackMethods?: boolean } = {}) {
+    warning(
+      false,
+      '`TestBot` is under heavy development. API may change between any versions.'
+    );
+    const connector = new TestConnector({ fallbackMethods });
+    super({ connector, sessionStore, sync: true });
+  }
+
+  async runTests(tests: Array<string>) {
+    const client = ((this.connector: any): TestConnector).client;
+    const requestHandler = this.createRequestHandler();
+
+    const rawEvents = tests.map(t => this._createRawEventFromTest(t));
+
+    const result = [];
+
+    for (let i = 0; i < tests.length; i++) {
+      /* eslint-disable no-await-in-loop */
+      const test = tests[i];
+
+      await requestHandler({
+        payload: '__GET_STARTED__',
+      });
+
+      client.mockReset();
+
+      // TODO: how to get errors from out of try catch boundary
+      await requestHandler(rawEvents[i]);
+
+      result.push({
+        input: test,
+        output: {
+          calls: client.calls,
+          error: null,
+        },
+      });
+
+      client.mockReset();
+      /* eslint-enable no-await-in-loop */
+    }
+
+    return result;
+  }
+
+  _createRawEventFromTest(test: string) {
+    if (/^\/payload /.test(test)) {
+      const payload = test.split('/payload ')[1];
+
+      return {
+        payload,
+      };
+    }
+
+    return {
+      message: {
+        text: test,
+      },
+    };
+  }
+}
diff --git a/src/bot/TestConnector.js b/src/bot/TestConnector.js
new file mode 100644
index 00000000..cc43c7f8
--- /dev/null
+++ b/src/bot/TestConnector.js
@@ -0,0 +1,93 @@
+/* @flow */
+
+import TestContext from '../context/TestContext';
+import TestEvent, { type TestRawEvent } from '../context/TestEvent';
+import type { Session } from '../session/Session';
+
+import type { Connector } from './Connector';
+
+type TestRequestBody = TestRawEvent;
+
+export type TestClient = {
+  calls: Array<{
+    name: string,
+    args: Array<any>,
+  }>,
+  callMethod(name: string, args: Array<any>): void,
+  mockReset(): void,
+};
+
+type ConstructorOptions = {|
+  client?: TestClient,
+  fallbackMethods?: boolean,
+|};
+
+const testClient = {
+  calls: [],
+  callMethod(name, args) {
+    this.calls.push({
+      name,
+      args,
+    });
+  },
+  mockReset() {
+    this.calls = [];
+  },
+};
+
+export default class TestConnector implements Connector<TestRequestBody> {
+  _client: TestClient;
+  _fallbackMethods: boolean;
+
+  constructor({ client, fallbackMethods }: ConstructorOptions = {}) {
+    this._client = client || testClient;
+    this._fallbackMethods = fallbackMethods || false;
+  }
+
+  get platform(): string {
+    return 'test';
+  }
+
+  get client(): TestClient {
+    return this._client;
+  }
+
+  getUniqueSessionKey(): string {
+    return '1';
+  }
+
+  async updateSession(session: Session): Promise<void> {
+    if (!session.user) {
+      session.user = {
+        id: '1',
+        name: 'you',
+        _updatedAt: new Date().toISOString(),
+      };
+    }
+
+    Object.freeze(session.user);
+    Object.defineProperty(session, 'user', {
+      configurable: false,
+      enumerable: true,
+      writable: false,
+      value: session.user,
+    });
+  }
+
+  mapRequestToEvents(body: TestRequestBody): Array<TestEvent> {
+    return [new TestEvent(body)];
+  }
+
+  createContext(params: {
+    event: TestEvent,
+    session: ?Session,
+    initialState: ?Object,
+    requestContext: ?Object,
+  }) {
+    return new TestContext({
+      ...params,
+      client: this._client,
+      fallbackMethods: this._fallbackMethods,
+    });
+  }
+}
diff --git a/src/bot/__tests__/TestBot.spec.js b/src/bot/__tests__/TestBot.spec.js
new file mode 100644
index 00000000..85d415d4
--- /dev/null
+++ b/src/bot/__tests__/TestBot.spec.js
@@ -0,0 +1,78 @@
+import TestBot from '../TestBot';
+import TestConnector from '../TestConnector';
+
+it('should construct bot with TestConnector', () => {
+  const bot = new TestBot();
+  expect(bot).toBeDefined();
+  expect(bot.onEvent).toBeDefined();
+  expect(bot.createRequestHandler).toBeDefined();
+  expect(bot.connector).toBeDefined();
+  expect(bot.connector).toBeInstanceOf(TestConnector);
+});
+
+it('should export runTests method', () => {
+  const bot = new TestBot();
+  expect(bot.runTests).toBeDefined();
+});
+
+describe('runTests', () => {
+  it('should work', async () => {
+    const bot = new TestBot();
+    const handler = async context => {
+      if (context.event.text === 'hello') {
+        await context.sendText('hello +1');
+      } else if (context.event.text === 'world') {
+        await context.sendText('world +1');
+      }
+    };
+
+    bot.onEvent(handler);
+
+    const result = await bot.runTests(['hello', 'world']);
+
+    expect(result).toEqual([
+      {
+        input: 'hello',
+        output: {
+          calls: [{ args: ['hello +1'], name: 'sendText' }],
+          error: null,
+        },
+      },
+      {
+        input: 'world',
+        output: {
+          calls: [{ args: ['world +1'], name: 'sendText' }],
+          error: null,
+        },
+      },
+    ]);
+  });
+
+  xit('should handle error', async () => {
+    const bot = new TestBot();
+    const handler = async () => {
+      throw new Error('xxx');
+    };
+
+    bot.onEvent(handler);
+
+    const result = await bot.runTests(['hello', 'world']);
+
+    expect(result).toEqual([
+      {
+        input: 'hello',
+        output: {
+          calls: [],
+          error: {},
+        },
+      },
+      {
+        input: 'world',
+        output: {
+          calls: [],
+          error: {},
+        },
+      },
+    ]);
+  });
+});
diff --git a/src/bot/__tests__/TestConnector.spec.js b/src/bot/__tests__/TestConnector.spec.js
new file mode 100644
index 00000000..9f8ad81b
--- /dev/null
+++ b/src/bot/__tests__/TestConnector.spec.js
@@ -0,0 +1,110 @@
+import TestConnector from '../TestConnector';
+import TestEvent from '../../context/TestEvent';
+import TestContext from '../../context/TestContext';
+
+const request = {
+  message: {
+    text: 'hello',
+  },
+};
+
+function setup() {
+  return {
+    connector: new TestConnector(),
+  };
+}
+
+describe('#platform', () => {
+  it('should be test', () => {
+    const { connector } = setup();
+    expect(connector.platform).toBe('test');
+  });
+});
+
+describe('#client', () => {
+  it('should be defined', () => {
+    const { connector } = setup();
+    expect(connector.client).toBeDefined();
+  });
+
+  it('support custom client', () => {
+    const client = {};
+    const connector = new TestConnector({ client });
+    expect(connector.client).toBe(client);
+  });
+
+  describe('#callMethod', () => {
+    it('should push to calls', () => {
+      const { connector } = setup();
+
+      connector.client.callMethod('sendText', 'hello');
+
+      expect(connector.client.calls).toEqual([
+        { name: 'sendText', args: 'hello' },
+      ]);
+    });
+  });
+
+  describe('#mockReset', () => {
+    it('should call stdout', () => {
+      const { connector } = setup();
+
+      connector.client.callMethod('sendText', 'hello');
+
+      connector.client.mockReset();
+
+      expect(connector.client.calls).toEqual([]);
+    });
+  });
+});
+
+describe('#getUniqueSessionKey', () => {
+  it('always return 1', () => {
+    const { connector } = setup();
+    const senderId = connector.getUniqueSessionKey(request.body);
+    expect(senderId).toBe('1');
+  });
+});
+
+describe('#updateSession', () => {
+  it('always attach sample dummy user', async () => {
+    const { connector } = setup();
+    const session = {};
+
+    await connector.updateSession(session);
+
+    expect(session).toEqual({
+      user: {
+        id: '1',
+        name: 'you',
+        _updatedAt: expect.any(String),
+      },
+    });
+  });
+});
+
+describe('#mapRequestToEvents', () => {
+  it('should map request to TestEvents', () => {
+    const { connector } = setup();
+    const events = connector.mapRequestToEvents(request.body);
+
+    expect(events).toHaveLength(1);
+    expect(events[0]).toBeInstanceOf(TestEvent);
+  });
+});
+
+describe('#createContext', () => {
+  it('should create TestContext', () => {
+    const { connector } = setup();
+    const event = {};
+    const session = {};
+
+    const context = connector.createContext({
+      event,
+      session,
+    });
+
+    expect(context).toBeDefined();
+    expect(context).toBeInstanceOf(TestContext);
+  });
+});
diff --git a/src/cli/providers/sh/__tests__/index.spec.js b/src/cli/providers/sh/__tests__/index.spec.js
index e0276b35..8e48a333 100644
--- a/src/cli/providers/sh/__tests__/index.spec.js
+++ b/src/cli/providers/sh/__tests__/index.spec.js
@@ -2,6 +2,7 @@ import sh from '..';
 
 jest.mock('../init');
 jest.mock('../help');
+jest.mock('../test');
 
 describe('sh cli', () => {
   it('should exist', () => {
@@ -17,4 +18,9 @@ describe('sh cli', () => {
     const help = require('../help').default;
     expect(sh.help).toEqual(help);
   });
+
+  it('should return test module', () => {
+    const test = require('../test').default;
+    expect(sh.test).toEqual(test);
+  });
 });
diff --git a/src/cli/providers/sh/__tests__/test.spec.js b/src/cli/providers/sh/__tests__/test.spec.js
new file mode 100644
index 00000000..6f5758c4
--- /dev/null
+++ b/src/cli/providers/sh/__tests__/test.spec.js
@@ -0,0 +1,114 @@
+import path from 'path';
+
+import fs from 'fs-extra';
+
+import testCommand from '../test';
+import loadModule from '../../../shared/loadModule';
+
+jest.mock('path');
+jest.mock('fs-extra');
+jest.mock('../../../shared/log');
+jest.mock('../../../shared/loadModule');
+
+const { error } = require('../../../shared/log');
+
+beforeEach(() => {
+  console.log = jest.fn();
+  process.exit = jest.fn();
+});
+
+it('should print error message when cannot find handler', async () => {
+  await testCommand({
+    argv: {
+      _: ['test', 'input.json'],
+    },
+  });
+
+  expect(loadModule).toBeCalledWith('handler');
+  expect(loadModule).toBeCalledWith('src/handler');
+
+  expect(error).toBeCalledWith('cannot find handler');
+  expect(process.exit).toBeCalledWith(1);
+});
+
+it('should run bot tests', async () => {
+  path.resolve.mockImplementation(filename => `/Users/xxx/project/${filename}`);
+  fs.readJson.mockResolvedValue(['hello', 'world']);
+  loadModule.mockReturnValue(async context => {
+    await context.sendText(`${context.event.text} +1`);
+  });
+
+  await testCommand({
+    argv: {
+      _: ['test', 'input.json'],
+    },
+  });
+
+  expect(path.resolve).toBeCalledWith('input.json');
+  expect(fs.readJson).toBeCalledWith('/Users/xxx/project/input.json');
+
+  expect(console.log).toBeCalledWith(
+    `[
+  {
+    "input": "hello",
+    "output": {
+      "calls": [
+        {
+          "name": "sendText",
+          "args": [
+            "hello +1"
+          ]
+        }
+      ],
+      "error": null
+    }
+  },
+  {
+    "input": "world",
+    "output": {
+      "calls": [
+        {
+          "name": "sendText",
+          "args": [
+            "world +1"
+          ]
+        }
+      ],
+      "error": null
+    }
+  }
+]`
+  );
+});
+
+it('should write to output file', async () => {
+  path.resolve.mockImplementation(filename => `/Users/xxx/project/${filename}`);
+  fs.readJson.mockResolvedValue(['hello', 'world']);
+  loadModule.mockReturnValue(async context => {
+    await context.sendText(`${context.event.text} +1`);
+  });
+
+  await testCommand({
+    argv: {
+      _: ['test', 'input.json'],
+      o: 'output.json',
+    },
+  });
+
+  expect(fs.writeJSON).toBeCalledWith('/Users/xxx/project/output.json', [
+    {
+      input: 'hello',
+      output: {
+        calls: [{ args: ['hello +1'], name: 'sendText' }],
+        error: null,
+      },
+    },
+    {
+      input: 'world',
+      output: {
+        calls: [{ args: ['world +1'], name: 'sendText' }],
+        error: null,
+      },
+    },
+  ]);
+});
diff --git a/src/cli/providers/sh/test.js b/src/cli/providers/sh/test.js
new file mode 100644
index 00000000..0d99fbe3
--- /dev/null
+++ b/src/cli/providers/sh/test.js
@@ -0,0 +1,44 @@
+import path from 'path';
+
+import fs from 'fs-extra';
+
+import TestBot from '../../../bot/TestBot';
+import { print, error } from '../../shared/log';
+import loadModule from '../../shared/loadModule';
+
+const test = async ({ argv }) => {
+  const inputFile = argv._[1];
+  const outputFile = argv['out-file'] || argv.o;
+
+  const inputFilePath = path.resolve(inputFile);
+
+  const tests = await fs.readJson(inputFilePath);
+
+  const bot = new TestBot();
+
+  // FIXME: how to find handler and initialState?
+  // find from ./handler or ./src/handler
+  const handler = loadModule('handler') || loadModule('src/handler');
+
+  if (!handler) {
+    error('cannot find handler');
+    return process.exit(1);
+  }
+
+  bot.onEvent(handler);
+
+  print('start running tests...');
+
+  const result = await bot.runTests(tests);
+
+  if (outputFile) {
+    const outputFilePath = path.resolve(outputFile);
+
+    await fs.writeJson(outputFilePath, result);
+  } else {
+    // TODO: how to prettify output
+    console.log(JSON.stringify(result, null, 2));
+  }
+};
+
+export default test;
diff --git a/src/context/TestContext.js b/src/context/TestContext.js
new file mode 100644
index 00000000..45f993f3
--- /dev/null
+++ b/src/context/TestContext.js
@@ -0,0 +1,90 @@
+/* @flow */
+import sleep from 'delay';
+
+import type { TestClient } from '../bot/TestConnector';
+import type { Session } from '../session/Session';
+
+import Context from './Context';
+import TestEvent from './TestEvent';
+import type { PlatformContext } from './PlatformContext';
+
+type Options = {|
+  client: TestClient,
+  event: TestEvent,
+  session: ?Session,
+  initialState: ?Object,
+  requestContext: ?Object,
+  fallbackMethods: boolean,
+|};
+
+const methodBlackList = [
+  'then', // promise
+  'handlerDidEnd', // context lifecycle
+];
+
+export default class TestContext extends Context implements PlatformContext {
+  _client: TestClient;
+  _event: TestEvent;
+  _session: ?Session;
+
+  constructor({
+    client,
+    event,
+    session,
+    initialState,
+    requestContext,
+    fallbackMethods,
+  }: Options) {
+    super({ client, event, session, initialState, requestContext });
+    if (fallbackMethods) {
+      // $FlowExpectedError
+      return new Proxy(this, {
+        get(target, key) {
+          // https://github.com/facebook/flow/issues/6181
+          // https://github.com/facebook/flow/issues/6321
+          // $FlowFixMe: Cannot get `target[key]` because an indexer property is missing in `TestContext` [1].
+          if (typeof target[key] !== 'undefined') {
+            // $FlowFixMe: Cannot get `target[key]` because an indexer property is missing in `TestContext` [1].
+            return target[key];
+          }
+
+          if (methodBlackList.some(method => method === key)) return;
+          return async (...args) => {
+            await target._methodMissing(key, args);
+          };
+        },
+      });
+    }
+  }
+
+  /**
+   * The name of the platform.
+   *
+   */
+  get platform(): string {
+    return 'test';
+  }
+
+  /**
+   * Delay and show indicators for milliseconds.
+   *
+   */
+  async typing(milliseconds: number): Promise<void> {
+    if (milliseconds > 0) {
+      await sleep(milliseconds);
+    }
+  }
+
+  /**
+   * Send text to the owner of then session.
+   *
+   */
+  async sendText(text: string): Promise<void> {
+    return this._methodMissing('sendText', [text]);
+  }
+
+  async _methodMissing(method: string, args: Array<any>): Promise<void> {
+    this._isHandled = true;
+    this._client.callMethod(method, args);
+  }
+}
diff --git a/src/context/TestEvent.js b/src/context/TestEvent.js
new file mode 100644
index 00000000..a7b4f2eb
--- /dev/null
+++ b/src/context/TestEvent.js
@@ -0,0 +1,82 @@
+/* @flow */
+
+import type { Event } from './Event';
+
+type Message = {
+  text: string,
+};
+
+export type TestRawEvent = {
+  message?: Message,
+  payload?: string,
+};
+
+export default class TestEvent implements Event {
+  _rawEvent: TestRawEvent;
+
+  constructor(rawEvent: TestRawEvent) {
+    this._rawEvent = rawEvent;
+  }
+
+  /**
+   * Underlying raw event from Test.
+   *
+   */
+  get rawEvent(): TestRawEvent {
+    return this._rawEvent;
+  }
+
+  /**
+   * Determine if the event is a message event.
+   *
+   */
+  get isMessage(): boolean {
+    return !!this._rawEvent.message;
+  }
+
+  /**
+   * The message object from Test raw event.
+   *
+   */
+  get message(): ?Message {
+    return this._rawEvent.message || null;
+  }
+
+  /**
+   * Determine if the event is a message event which includes text.
+   *
+   */
+  get isText(): boolean {
+    if (this.isMessage) {
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * The text string from Test raw event.
+   *
+   */
+  get text(): ?string {
+    if (this.isText) {
+      return ((this.message: any): Message).text;
+    }
+    return null;
+  }
+
+  /**
+   * Determine if the event is a payload event.
+   *
+   */
+  get isPayload(): boolean {
+    return !!this._rawEvent.payload;
+  }
+
+  /**
+   * The payload string from Test raw event.
+   *
+   */
+  get payload(): ?string {
+    return this._rawEvent.payload || null;
+  }
+}
diff --git a/src/context/__tests__/TestContext.spec.js b/src/context/__tests__/TestContext.spec.js
new file mode 100644
index 00000000..63459e59
--- /dev/null
+++ b/src/context/__tests__/TestContext.spec.js
@@ -0,0 +1,142 @@
+jest.mock('delay');
+
+let TestContext;
+let TestEvent;
+let sleep;
+
+beforeEach(() => {
+  /* eslint-disable global-require */
+  TestContext = require('../TestContext').default;
+  TestEvent = require('../TestEvent').default;
+  sleep = require('delay');
+  /* eslint-enable global-require */
+});
+
+afterEach(() => {
+  jest.useFakeTimers();
+});
+
+const rawEvent = {
+  message: {
+    text: 'Hello, world',
+  },
+};
+
+const userSession = {
+  user: {
+    id: '1',
+    name: 'you',
+  },
+};
+
+const setup = ({ session, fallbackMethods } = { session: userSession }) => {
+  const client = {
+    callMethod: jest.fn(),
+  };
+  const context = new TestContext({
+    client,
+    event: new TestEvent(rawEvent),
+    session,
+    fallbackMethods: fallbackMethods || false,
+  });
+  return {
+    client,
+    context,
+    session,
+  };
+};
+
+it('be defined', () => {
+  const { context } = setup();
+  expect(context).toBeDefined();
+});
+
+it('#platform to be `test`', () => {
+  const { context } = setup();
+  expect(context.platform).toBe('test');
+});
+
+it('get #session works', () => {
+  const { context, session } = setup();
+  expect(context.session).toBe(session);
+});
+
+it('get #event works', () => {
+  const { context } = setup();
+  expect(context.event).toBeInstanceOf(TestEvent);
+});
+
+it('get #client works', () => {
+  const { context, client } = setup();
+  expect(context.client).toBe(client);
+});
+
+describe('#sendText', () => {
+  it('should call client.callMethod with sendText and text', async () => {
+    const { context, client } = setup();
+
+    await context.sendText('hello');
+
+    expect(client.callMethod).toBeCalledWith('sendText', ['hello']);
+  });
+
+  it('should mark context as handled', async () => {
+    const { context } = setup();
+
+    await context.sendText('hello');
+
+    expect(context.isHandled).toBe(true);
+  });
+});
+
+describe('method missing', () => {
+  it('should call callMethod', async () => {
+    const { context, client } = setup({ fallbackMethods: true });
+
+    await context.sendABC('hello', { json: true });
+
+    expect(client.callMethod).toBeCalledWith('sendABC', [
+      'hello',
+      { json: true },
+    ]);
+  });
+
+  it('should mark context as handled', async () => {
+    const { context } = setup({ fallbackMethods: true });
+
+    await context.sendABC('hello');
+
+    expect(context.isHandled).toBe(true);
+  });
+
+  it('should not proxy blacklisted methods', async () => {
+    const { context } = setup({ fallbackMethods: true });
+
+    expect(context.handlerDidEnd).toBeUndefined();
+    expect(context.then).toBeUndefined();
+  });
+
+  it('should not proxy falsy getters', async () => {
+    const { context } = setup({ fallbackMethods: true });
+
+    expect(context.isSessionWritten).toBe(false);
+  });
+});
+
+describe('#typing', () => {
+  it('avoid delay 0', async () => {
+    const { context } = setup();
+
+    await context.typing(0);
+
+    expect(sleep).not.toBeCalled();
+  });
+
+  it('should call sleep', async () => {
+    const { context } = setup();
+
+    await context.typing(10);
+
+    expect(sleep).toBeCalled();
+  });
+});
diff --git a/src/context/__tests__/TestEvent.spec.js b/src/context/__tests__/TestEvent.spec.js
new file mode 100644
index 00000000..a63fb849
--- /dev/null
+++ b/src/context/__tests__/TestEvent.spec.js
@@ -0,0 +1,48 @@
+import TestEvent from '../TestEvent';
+
+const textMessage = {
+  message: {
+    text: 'Hello, world',
+  },
+};
+
+const payload = {
+  payload: 'A_PAYLOAD',
+};
+
+it('#rawEvent', () => {
+  expect(new TestEvent(textMessage).rawEvent).toEqual(textMessage);
+  expect(new TestEvent(payload).rawEvent).toEqual(payload);
+});
+
+it('#isMessage', () => {
+  expect(new TestEvent(textMessage).isMessage).toEqual(true);
+  expect(new TestEvent(payload).isMessage).toEqual(false);
+});
+
+it('#message', () => {
+  expect(new TestEvent(textMessage).message).toEqual({
+    text: 'Hello, world',
+  });
+  expect(new TestEvent(payload).message).toEqual(null);
+});
+
+it('#isText', () => {
+  expect(new TestEvent(textMessage).isText).toEqual(true);
+  expect(new TestEvent(payload).isText).toEqual(false);
+});
+
+it('text', () => {
+  expect(new TestEvent(textMessage).text).toEqual('Hello, world');
+  expect(new TestEvent(payload).text).toEqual(null);
+});
+
+it('#isPayload', () => {
+  expect(new TestEvent(textMessage).isPayload).toEqual(false);
+  expect(new TestEvent(payload).isPayload).toEqual(true);
+});
+
+it('payload', () => {
+  expect(new TestEvent(textMessage).payload).toEqual(null);
+  expect(new TestEvent(payload).payload).toEqual('A_PAYLOAD');
+});