Notes Used to Craft this Lesson
Okay, so next we have in our PostsController a store method mimicking a post creation, along with validation.
export default class PostsController { // ... async store({ request, response }: HttpContext) { const data = await request.validateUsing(postValidator) const post = { id: randomInt(100), ...data, } return response.created(post) } // ... }Copied!
- app
- controllers
- posts_controller.ts
There are several things to easily trip over here, so let's walk through things and trip over them together.
First, we'll write our test for our posts.store. We'll need a payload to send up to serve as our request body.
test("create a new post when valid data is provided", async ({ client }) => { const payload = { title: "A super cool post", summary: "This is the summary of a super cool post", body: "My super cool posts body", }; });Copied!
Then, we'll send our request and assert our responses as we did before. Previously, though, we were using the actual router service to make our URLs from their identifiers. The test does actually provide a shorthand utility for this as well, called route. It does the same thing, just with a smaller footprint, which is nice.
test("create a new post when valid data is provided", async ({ client, route, }) => { const payload = { title: "A super cool post", summary: "This is the summary of a super cool post", body: "My super cool posts body", }; const response = await client.post(route("posts.store")).json(payload); response.assertCreated(); response.assertBodyContains(payload); });Copied!
Japa's API Client contains both a json method to send a JSON payload in our request body and a form method to mimic your typical HTML form submission.
Everything looks fine with our test, but we've tripped over our first stick because our test is failing. We can see via the error logged to our console that it failed due to an invalid CSRF token. Remember, CSRF protection is on by default in the web starter kit for POST, PUT, PATCH, and DELETE verbs, and this is the first of those we're sending.
To fix this, we'll need to add two plugins from the AdonisJS session and shield packages. Session will add session utilities, Shield will add Shield utilities, and when it comes to CSRF, they'll work together. Shield will set the CSRF token, and Session will store it per test.
// ... import { sessionApiClient } from "@adonisjs/session/plugins/api_client"; import { shieldApiClient } from "@adonisjs/shield/plugins/api_client"; export const plugins: Config["plugins"] = [ assert(), apiClient(), pluginAdonisJS(app), sessionApiClient(app), shieldApiClient(), disallowPinnedTests({ disallow: !!process.env.CI, }), ];Copied!
- tests
- bootstrap.ts
With those added, our API Client now has session and shield utilities added to it, one of which is withCsrfToken, so we'll add that to our request.
test("create a new post when valid data is provided", async ({ client, route, }) => { const payload = { title: "A super cool post", summary: "This is the summary of a super cool post", body: "My super cool posts body", }; const response = await client .post(route("posts.store")) .json(payload) .withCsrfToken(); response.assertCreated(); response.assertBodyContains(payload); });Copied!
Perfect! Although even with that added, we can see our test is still failing due to an invalid CSRF token... we've tripped over stick number two.
Our session store isn't working properly because it's trying to work in its traditional way, via cookies. Our tests, however, don't work that way because there is no browser in the picture. So, we need to switch our session store to be in the memory of our server. We can easily do that via a test-specific environment variable.
# .env.test
NODE_ENV=test
SESSION_DRIVER=memoryWhen we run our test commands, AdonisJS will automatically search for a .env.test file to use. It'll prefer values defined within .env.test over those within our .env for our tests, which will in turn set our session driver to use a memory store.
With that saved, bam! Our test is now succeeding. Perfect!
We do, however, need to also test our sad path yet, so let's take care of that! Let's assume we'll get back a 422 Unprocessable Entity for our validation error.
test("fail to create a post when the title is omitted", async ({ client, route, }) => { const payload = { summary: "This is the summary of a super cool post", body: "My super cool posts body", }; const response = await client .post(route("posts.store")) .json(payload) .withCsrfToken(); response.assertUnprocessableEntity(); // 422 });Copied!
Hmm, rats... we've tripped over our third stick. But, we've tripped over this one before! What we're seeing here is the web behavior for HTML responses. So, let's again try telling our endpoint we expect application/json.
test("fail to create a post when the title is omitted", async ({ client, route, }) => { const payload = { summary: "This is the summary of a super cool post", body: "My super cool posts body", }; const response = await client .post(route("posts.store")) .header("Accept", "application/json") .json(payload) .withCsrfToken(); response.assertUnprocessableEntity(); });Copied!
Perfect! We're back to working now! Let's take this a step further and assert our validation error.
test("fail to create a post when the title is omitted (json)", async ({ client, route, }) => { const payload = { summary: "This is the summary of a super cool post", body: "My super cool posts body", }; const response = await client .post(route("posts.store")) .header("Accept", "application/json") .json(payload) .withCsrfToken(); response.assertUnprocessableEntity(); response.assertBodyContains({ errors: [ { field: "title", rule: "required", }, ], }); });Copied!
Again, note that with assertBodyContains we merely need to match the structure, not the entirety of what's provided.
Great, now what if we were actually working with HTML forms and expected an HTML response? As a reminder, HTML validation error responses will flash the errors and redirect the user back.
test("fail to create a post when the title is omitted (html)", async ({ client, route, }) => { const payload = { summary: "This is the summary of a super cool post", body: "My super cool posts body", }; const response = await client .post(route("posts.store")) .header("Referer", "/posts/create") // define a referer so we can ensure redirection .form(payload) // switch to `form` .withCsrfToken(); response.assertRedirectsTo("/posts/create"); });Copied!
Now, that works, but what if we also want to ensure our validation error is flashed? By default, the API Client will follow up to five redirects. So, what we are actually asserting here is our /posts/create page, since it redirected us back to the referer with the validation error. Since this page doesn't actually exist, it's a 404 Youch page.
An option we have here is to not follow the redirects, which would allow us to assert against the flash message store!
test("fail to create a post when the title is omitted (html)", async ({ client, route, }) => { const payload = { summary: "This is the summary of a super cool post", body: "My super cool posts body", }; const response = await client .post(route("posts.store")) .header("Referer", "/posts/create") .form(payload) .withCsrfToken() .redirects(0); // don't follow any redirects // response.assertRedirectsTo('/posts/create') // we can still assert the redirection via the response header response.assertHeader("Location", "/posts/create"); // but now we can also assert our flash message is present! response.assertFlashMessage("errors.title", [ "The title field must be defined", ]); });Copied!