Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test: E2E Mock API structure #2624

Merged
merged 15 commits into from
Nov 15, 2021
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
380 changes: 286 additions & 94 deletions docs/e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
3. [Good Practices](#good-practices)
4. [Mock API](#mock-api)

a. [Core Package](#core-package)

b. [How to create a support package](#how-to-create-a-support-package)

## Setup

Politeiagui has E2E tests using [`cypress`](https://www.cypress.io/) and
Expand Down Expand Up @@ -136,103 +140,291 @@ the way you specify.

However, intercept commands can be used on many different tests, hence, they
should be more generic. This is why we came up with **middlewares**, which are
nothing but an interceptor + alias. Let's see how can we use the
`/api/ticketvote/v1/inventory` interceptor as a middleware:

1. Create the middleware into our support mock file for the plugin you want.

- In this case, we call the `ticketvote` api, so we will add it into
`support/mock/ticketvote.js`:

```javascript
export const middlewares = {
// ...
inventory: () =>
cy.intercept("/api/ticketvote/v1/inventory", (req) => {
req.reply({
statusCode: 200, // set custom response status
body: {
vetted: {
unauthorized: ["abcdefg"] // return some fake token
}
}
});
})
// ...
};
```

2. Add the bundle into the cypress custom `cy.middleware` command on
`support/commands.js`:

```javascript
import { middlewares as ticketVoteMiddlewares } from "./mock/ticketvote";

// ...

Cypress.Commands.add("middleware", (path, ...args) => {
const mw = get(path)({
ticketvote: ticketVoteMiddlewares
// ... other middlewares
});
return mw(...args).as(path); // alias automatically generated
});

// ...
```

3. Now, when you want to use the middleware on your tests, you can just pass the
query selector for the middleware you need:

```javascript
// ============
// your-test.js
// ============
describe("Ticketvote Inventory", () => {
it("can fetch the ticketvote inventory", () => {
// accessing the middleware path using the string pattern:
cy.middleware("ticketvote.inventory");
// ...
// Assert request success
cy.wait("@ticketvote.inventory").its("status").should("eq", 200);
// ...
});
// ...
});
```

4. Now you can test pages that call the `/ticketvote/v1/inventory` without being
connected to the ticketvote api.

---

Let's build our proposals list page test example using the created ticketvote
inventory middleware. First, let's keep in mind that the proposals page also
requests the `api/records/v1/records` endpoint, so let's assume we have a
`records.records` middleware, which will return a random records batch for
given inventory tokens.
nothing but an interceptor + alias.

In order to make our codebase more scalable, we organized the support commands
and files according to the [politeia](https://github.com/decred/politeia) plugin
structure. Each plugin will have its own support files, which can extend the
core (records) support files and create custom commands. You can use the custom
commands to test the application.

### Core Package

Let's take a deeper look at the `teste2e/cypress/support/core`:

```bash
teste2e/cypress/support/core
├── api.js # mock api repliers
├── commands.js # custom plugin commands
├── generate.js # mock data generators
└── utils.js # support utils and helpers
```

#### `api.js`:

Contains all repliers for the plugin api calls. Each replier will accept one
object with two parameters: `testParams` and `requestParams`. Both describe
respectively test configuration parameters, which can be used to configure
the response to match test pre-conditions, and request data sent to the API.

Let's build some replier for the `/records/v1/inventory` call:

```javascript
describe("Proposals list", () => {
it("should render proposals list", () => {
// middlewares
cy.middleware("ticketvote.inventory");
cy.middleware("records.records");
// user behavior
cy.visit(`/`);
cy.wait("@ticketvote.inventory");
cy.wait("@records.records");
// assert that the proposal is fetched on the list
cy.findAllByTestId("record-title").should("have.length", 1);
// Scroll to bottom to render more proposals
cy.scrollTo("bottom");
cy.wait(1000);
// make sure no new proposals were fetched, since the inventory has only
// one token
cy.findAllByTestId("record-title").should("have.length", 1);
import { Inventory } from "./generate";
import { stateToString } from "./utils";

export function inventoryReply({
testParams: { amountByStatus = {}, pageLimit = 20 },
requestParams: { state = 2, page }
}) {
const inventory = new Inventory(amountByStatus, {
page,
pageLimit
});
return { [stateToString(state)]: inventory };
}
//...
```

> Tip: all repliers names should be followed by the `Reply` suffix, such as
> `inventoryReply`, `recordsReply`, `policyReply`...

As you can see, we are receiving `{ amountByStatus, pageLimit }` from the test
case, which describe the status and state pre-conditions to run the tests.
`{ state, page } ` is the request body object for the `/inventory` call (check
the [records api docs](https://github.com/decred/politeia/blob/7eb46182bbe319aebfa7e9a0cdad45e305dd41e0/politeiawww/api/records/v1/v1.go#L514)).

#### `commands.js`

Contains all custom cypress commands that can be used for testing the plugin
contents. See the [Cypress Docs](https://docs.cypress.io/api/cypress-api/custom-commands)
for more information about how to create custom commands.

The core package contains the custom commands that can be used by other
support plugins, and core support methods, which can be used to create custom
plugin commands, such as `createMiddleware`:

Let's build the `recordsMiddleware` command for the `records` package using
the `createMiddleware` method:

```javascript
import "@testing-library/cypress/add-commands";
import { inventoryReply, recordsReply } from "./api";
// ...
Cypress.Commands.add(
"recordsMiddleware",
createMiddleware({
packageName: "records",
repliers: {
// the replier we just created for the records/v1/inventory call
inventory: inventoryReply,
// other replier for the records/v1/records call
records: recordsReply
},
baseUrl: "/api/records/v1"
})
);
```

> Tip: all middlewares names should be followed by the `Middleware` suffix, such
> as `recordsMiddleware`, `ticketvoteMiddleware`, `commentsMiddleware`...

As mentioned before, "middlewares are nothing but an interceptor + alias", so
you can access the alias using the `"{packageName}.{endpoint}"` selector.

Now, we can use the `recordsMiddleware` to intercept the `/inventory` call:

```javascript
// mytest.js
it("should fetch the inventory on records page", () => {
// will automatically fetch the inventory
cy.visit("/records");
// will intercept the /records/v1/inventory call and return the mock data
cy.recordsMiddleware("inventory", {
amountByStatus: { authorized: 2, started: 3, unauthorized: 3 }
});
// wait for the call to be complete using the middleware alias.
cy.wait("@records.inventory");
// ...
});
```

Now, if we turn off the backend, the tests will still be able to render the
proposals list.
#### `generate.js`

Contains all mock data generators for the plugin. We can generate custom data
using the [faker NPM package](https://github.com/marak/Faker.js/), which is a
poweful tool to generate random data.

Let's create a mock `Record` generator to generate custom records for our tests:

```javascript
import faker from "faker";

/**
* Inventory instantiates a new inventory map with random tokens according to
* the amountByStatus ({ [status]: amount }) object.
* @param { Object } amountByStatus
* @param { Object } { pageLimit, page }
*/
export function Inventory(amountByStatus = {}, { pageLimit = 20, page = 1 }) {
return compose(
reduce(
(acc, [status, amount]) => ({
...acc,
[status]: compose(
get(page - 1),
chunk(pageLimit),
map(() => faker.git.shortSha().slice(0, 7)),
range(amount)
)(0)
}),
{}
),
entries
)(amountByStatus);
}
```

Usage:

```javascript
const inv = new Inventory({ public: 2, archived: 1 });
// { public: ["87fah87", "5h97h53"], archived: ["a3575ca"] }
```

> Tip: forcing generators to use the constructor pattern for our mock data is a
> good option, because we can extend the prototype and use the `instanceof`
> operator to enforce the "type ckecking". For example, we can check if some
> proposal is a record using `proposal instanceof Record` condition.

### How to create a support package

Previously, we talked about the core support package, but one of the greatest
features of our plugin extensible architecture is that you can use the core
package commands to create new custom support files. Let's build an example for
the `ticketvote` plugin and create a middleware for the
`ticketvote/v1/inventory` call.

1. Create a new `ticketvote` folder under the `teste2e/cypress/support`
dir including the new `api.js`, `commands.js`, `generate.js` and `utils.js`
files. It should look like this:

```
teste2e/cypress/support
├── core
│ ├── api.js
│ ├── commands.js
│ ├── generate.js
│ └── utils.js
└── ticketvote
├── api.js
├── commands.js
├── generate.js
└── utils.js
```

2. Create the API replier for the `/inventory` request:

```javascript
import { inventoryReply as recordsInventoryReply } from "../core/api";
import { statusToString } from "./utils";

export function inventoryReply(props, { status, ...requestParams } = {}) {
const inventory = recordsInventoryReply(props, requestParams);
if (status) {
const readableStatus = statusToString(status);
return Object.entries(inventory).reduce(
(acc, [state, statuses]) => ({
...acc,
[state]: {
[readableStatus]: statuses[readableStatus]
}
}),
{}
);
}
return inventory;
}
```

Notice that we can use the same `recordsInventoryReply` as a replier to the
`/inventory` call, but we can also extend the replier and modify it to
whatever we want. In this case, we want to filter the inventory to match the
request status, so we can only reply tokens for the given status, if it
exists.

3. Create the `ticketvote` middleware using the core `createMiddleware` method:

```javascript
import "@testing-library/cypress/add-commands";
import { createMiddleware } from "../core/commands";
import { inventoryReply } from "./api";

Cypress.Commands.add(
"ticketvoteMiddleware",
createMiddleware({
packageName: "ticketvote",
repliers: {
inventory: inventoryReply
},
baseUrl: "/api/ticketvote/v1"
})
);
```

4. Write the test case under `teste2e/cypress/e2e` dir:

```javascript
// mytest.js
it("should fetch the ticketvote inventory on home page", () => {
// ticketvote page will automatically fetch the inventory
cy.visit("/ticketvote");
// will intercept the /ticketvote/v1/inventory call and return
// the mock data
cy.ticketvoteMiddleware("inventory", {
amountByStatus: { authorized: 1, started: 1, unauthorized: 1 }
});
// wait for the call to be complete using the middleware alias.
cy.wait("@ticketvote.inventory").then((response) => {
// {
// vetted: {
// authorized: ["b5aa3b2"],
// started: ["ab76379"],
// unauthorized: ["d9a78d5"]
// }
// }
});
// Will automatically fetch the authorized tokens
cy.visit("/ticketvote/authorized");
cy.wait("@ticketvote.inventory").then((response) => {
// {
// vetted: {
// authorized: ["b5aa3b2"]
// }
// }
});
// ...
});
```

> Tip: test cases are linked to the application, while the support packages
> are linked to the backend structure.

5. You can combine the `recordsMiddleware` and `ticketvoteMiddleware` to
generate custom records for the ticketvote inventory tokens:

```javascript
it("should fetch records from ticketvote inventory", () => {
cy.ticketvoteMiddleware("inventory", {
amountByStatus: { authorized: 1, started: 1, unauthorized: 1 }
});
cy.recordsMiddleware("records", { state: 2, status: 2 });

cy.visit("ticketvote/records?tab=authorized");
cy.findAllByTestId("record").should("have.length", 1); // OK
cy.visit("ticketvote/records?tab=started");
cy.findAllByTestId("record").should("have.length", 1); // OK
cy.visit("ticketvote/records?tab=unauthorized");
cy.findAllByTestId("record").should("have.length", 1); // OK
});
```