Skip to content

Commit

Permalink
feat: add support for opening channels and asserting on messages (#5)
Browse files Browse the repository at this point in the history
* Overhauled the 'activeView' concept to support an 'activeScreen'
* `getByText` and `interactWith` respect the active screen, and look through views or messages where appropriate
* Added a buildTeam fixture generator
* Documented the fixtures generator
* Now supports mentioning the app in a message via `mentionApp({ channelId: string })`, which fires the app_mention event under the hood
* Added tests for the `startServer` util and `getByText` method
  • Loading branch information
chrishutchinson authored Aug 30, 2021
1 parent a803fb9 commit 5ec4c09
Show file tree
Hide file tree
Showing 11 changed files with 638 additions and 89 deletions.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@ node_modules
.DS_Store

# Library
dist
dist

# Jest
coverage
51 changes: 49 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ Slack Testing Library allows you to run integration tests against your Slack app

It is designed to use simple methods that describe how real users will interact with your Slack app (e.g. `openHome()` or `interactWith("button")`), helping you to test the expected behaviour of your application, not the implementation details.

### Active screen

Slack Testing Library maintains an understanding of the currently active screen, just like a user in Slack would. By having an 'active screen', interaction and assertions can be made against that screen. Using methods like `openHome()` or `openChannel()` will change the active screen accordingly.

## Getting started

1. Set up your API server to route Slack API requests to the Slack Testing Library intercept server
Expand Down Expand Up @@ -115,7 +119,7 @@ sl.intercept("conversations.info", () => ({

#### `openHome(): Promise<void>`

This triggers the `app_home_opened` event, and waits for a `views.publish` request from your application server.
This triggers the `app_home_opened` event, and waits for a `views.publish` request from your application server. The [active screen](#active-scren) will be set to the App Home.

> Note: This method requires an actor to be passed to the `SlackTestingLibrary` initializer, or via the `actAs` method before your test is run.

Expand All @@ -138,6 +142,26 @@ sl.actAs({
});
```

#### `openChannel(channelId: string): Promise<void>`

This sets the active screen to be the channel with the given ID. See more about [setting the active screen](#active-scren).

#### `mentionApp({ channelId: string }): Promise<void>`

This mentions the current app (bot ID can be configured in the `SlackTestingLibrary` initializer) in the given channel. This can be useful when combined with the `openChannel()` method, to open the channel and mention the app, and asserting on a response message.

Example usage:

```ts
sl.openChannel(channelId);
await sl.mentionApp({
channelId,
});
await sl.getByText("Some text");
```

### Finding elements and interacting with them

#### `getByText(): Promise<void>`
Expand All @@ -149,7 +173,7 @@ This allows you to find a specific string or piece of text within a the current
await sl.getByText("Hello, world!");
```

> Note: At the moment this is limited to "section" and "header" blocks. In future this will expand to include support for looking inside messages, ephemeral messages, and other elements (including buttons and input controls).
> Note: At the moment this is limited to "section" and "header" blocks, and can only look at the App Home view, or within standard messages in channels.

#### `interactWith(): Promise<void>`

Expand All @@ -159,3 +183,26 @@ This allows you to find an interactive element (e.g. buttons) and interact with
// This would click the button with the text "Refresh", and fail if the button could not be found
await sl.interactWith("button", "Refresh");
```

## Fixtures

Slack Testing Library ships with some helper methods for generating common fixture data, useful for mocking out Slack responses.

### `SlackTestingLibrary.fixtures.buildChannel(overrides: Partial<SlackChannel>): SlackChannel`

This builds a Slack Channel, useful for responding to `conversations.info` requests.

Example usage:

```ts
sl.intercept("conversations.info", () => ({
channel: SlackTestingLibrary.fixtures.buildChannel({
name: "my-custom-private-channel",
is_private: true,
}),
}));
```

### `SlackTestingLibrary.fixtures.buildTeam(overrides: Partial<SlackTeam>): SlackTeam`

This builds a Slack Team or Workspace, useful for responding to `team.info` requests.
236 changes: 208 additions & 28 deletions src/__tests__/slack-testing-library.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
import { createServer, Server } from "http";
import { View } from "@slack/types";
import { Message } from "@slack/web-api/dist/response/ChatScheduleMessageResponse";
import { Server } from "http";
import { SlackTestingLibrary } from "../slack-testing-library";
import { startServer } from "../util/server";

jest.mock("../util/server");
jest.mock("http");

export const createMockServer = ({
listen,
close,
}: Partial<{
listen: (port: number, cb: Function) => void;
close: (cb: Function) => void;
}> = {}): Server => {
return {
listen: listen || ((_port: number, cb: Function) => cb()),
close: close || (() => {}),
} as unknown as Server;
};

describe("SlackTestingLibrary", () => {
beforeEach(() => {
jest.resetAllMocks();
});

describe("constructor", () => {
it("should return an instance of SlackTestingLibrary when correctly initialized", () => {
const slackTestingLibrary = new SlackTestingLibrary({
const sl = new SlackTestingLibrary({
baseUrl: "https://www.github.com/chrishutchinson/slack-testing-library",
actor: {
teamId: "T1234567",
Expand All @@ -19,65 +36,228 @@ describe("SlackTestingLibrary", () => {
port: 3840,
});

expect(slackTestingLibrary instanceof SlackTestingLibrary).toBe(true);
expect(sl instanceof SlackTestingLibrary).toBe(true);
});
});

describe("#init()", () => {
it("should create a HTTP server and start listening when called", async () => {
const slackTestingLibrary = new SlackTestingLibrary({
const sl = new SlackTestingLibrary({
baseUrl: "https://www.github.com/chrishutchinson/slack-testing-library",
});
(createServer as jest.Mock<Server>).mockImplementation(() => {
return {
listen: (_port: number, cb: Function) => cb(),
} as unknown as Server;
});

await slackTestingLibrary.init();
(startServer as jest.Mock<Promise<Server>>).mockImplementation(async () =>
createMockServer()
);

await sl.init();

expect(createServer).toHaveBeenCalled();
expect(startServer).toHaveBeenCalledWith(
expect.objectContaining({ port: 8123 })
);
});

it("should listen on a custom port if provided", async () => {
const slackTestingLibrary = new SlackTestingLibrary({
const sl = new SlackTestingLibrary({
baseUrl: "https://www.github.com/chrishutchinson/slack-testing-library",
port: 4001,
});
const mockListen = jest.fn((_port, cb) => cb());

(createServer as jest.Mock<Server>).mockImplementation(() => {
return {
listen: mockListen,
} as unknown as Server;
});
(startServer as jest.Mock<Promise<Server>>).mockImplementation(async () =>
createMockServer()
);

await slackTestingLibrary.init();
await sl.init();

expect(mockListen).toHaveBeenCalledWith(4001, expect.any(Function));
expect(startServer).toHaveBeenCalledWith(
expect.objectContaining({ port: 4001 })
);
});
});

describe("#teardown()", () => {
it("should stop the server if one is running", async () => {
const slackTestingLibrary = new SlackTestingLibrary({
const sl = new SlackTestingLibrary({
baseUrl: "https://www.github.com/chrishutchinson/slack-testing-library",
});

const mockClose = jest.fn((cb) => cb());

(createServer as jest.Mock<Server>).mockImplementation(() => {
return {
listen: (_port: number, cb: Function) => cb(),
(startServer as jest.Mock<Promise<Server>>).mockImplementation(async () =>
createMockServer({
close: mockClose,
} as unknown as Server;
});
})
);

await slackTestingLibrary.init();
await sl.init();

await slackTestingLibrary.teardown();
await sl.teardown();

expect(mockClose).toHaveBeenCalled();
});
});

describe("#getByText()", () => {
it("should throw an error if the server hasn't been initialised", async () => {
const sl = new SlackTestingLibrary({
baseUrl: "https://www.github.com/chrishutchinson/slack-testing-library",
});

await expect(sl.getByText("Sample")).rejects.toThrow(
"Start the Slack listening server first by awaiting `sl.init()`"
);
});
});

it("should throw an error if an active screen hasn't been set", async () => {
const sl = new SlackTestingLibrary({
baseUrl: "https://www.github.com/chrishutchinson/slack-testing-library",
});

(startServer as jest.Mock<Promise<Server>>).mockImplementation(async () =>
createMockServer()
);

await sl.init();

await expect(sl.getByText("Sample")).rejects.toThrow("No active screen");
});

describe("activeScreen: view", () => {
it("should throw if the provided text isn't in the view", async () => {
const sl = new SlackTestingLibrary({
baseUrl: "https://www.github.com/chrishutchinson/slack-testing-library",
actor: {
teamId: "T1234567",
userId: "U1234567",
},
});

(startServer as jest.Mock<Promise<Server>>).mockImplementation(
async ({ onViewChange }) => {
// Set the active screen
onViewChange({
blocks: [
{
type: "section",
text: {
text: "Match: 1234",
type: "plain_text",
},
},
],
} as View);

return createMockServer();
}
);

await sl.init();

await sl.openHome();

await expect(sl.getByText("Match: 5678")).rejects.toThrow(
'Unable to find the text "Match: 5678" in the current view'
);
});

it("should resolve if the provided text is in the view", async () => {
const sl = new SlackTestingLibrary({
baseUrl: "https://www.github.com/chrishutchinson/slack-testing-library",
actor: {
teamId: "T1234567",
userId: "U1234567",
},
});

(startServer as jest.Mock<Promise<Server>>).mockImplementation(
async ({ onViewChange }) => {
// Set the active screen
onViewChange({
blocks: [
{
type: "section",
text: {
text: "Match: 1234",
type: "plain_text",
},
},
],
} as View);

return createMockServer();
}
);

await sl.init();

await sl.openHome();

await sl.getByText("Match: 1234");
});
});

describe("activeScreen: channel", () => {
it("should throw if the provided text isn't in any messages in the channel", async () => {
const sl = new SlackTestingLibrary({
baseUrl: "https://www.github.com/chrishutchinson/slack-testing-library",
actor: {
teamId: "T1234567",
userId: "U1234567",
},
});

(startServer as jest.Mock<Promise<Server>>).mockImplementation(
async ({ onRecieveMessage }) => {
// Add to the message log
onRecieveMessage(
{
text: "Match: 1234",
} as Message,
"C1234567"
);

return createMockServer();
}
);

await sl.init();

await sl.openChannel("C1234567");

await expect(sl.getByText("Match: 5678")).rejects.toThrow(
'Unable to find the text "Match: 5678" in the current channel'
);
});

it("should resolve if the provided text is in the view", async () => {
const sl = new SlackTestingLibrary({
baseUrl: "https://www.github.com/chrishutchinson/slack-testing-library",
actor: {
teamId: "T1234567",
userId: "U1234567",
},
});

(startServer as jest.Mock<Promise<Server>>).mockImplementation(
async ({ onRecieveMessage }) => {
// Add to the message log
onRecieveMessage(
{
text: "Match: 1234",
} as Message,
"C1234567"
);

return createMockServer();
}
);

await sl.init();

await sl.openChannel("C1234567");

await sl.getByText("Match: 1234");
});
});
});
Loading

0 comments on commit 5ec4c09

Please sign in to comment.