Notes Used to Craft this Lesson
So, we have an endpoint that allows users to update their account email. This endpoint:
Validates the desired new email
Requires the user to enter their password for security
Updates the user's email
Logs the change within via Email History
Sends a notification email to the old email
So, we'll be working within our pre-existing account spec within our settings folder, and we want to test that it should "allow a user to change their account email" for our happy path.
We know this sends email, so we'll want to set up and restore our mail fake.
test("allow a user to change their account email", async ({ assert, client, route, cleanup, }) => { const { mails } = await mail.fake(); cleanup(() => mail.restore()); });Copied!
Then, we can prepare and send our request.
test("allow a user to change their account email", async ({ assert, client, route, cleanup, }) => { const { mails } = await mail.fake(); cleanup(() => mail.restore()); const user = await UserFactory.create(); const response = await client .put(route("settings.account.email")) .withCsrfToken() .header("Referer", route("settings.account")) .loginAs(user) .form({ email: "anewemail@test.com", password: "something", }) .redirects(0); });Copied!
This endpoint should redirect the user back to their referer on success, so we'll set this to not follow any redirects via redirects(0). This allows us to assert our flash messages, and we can also assert the redirect via the location header and status.
test("allow a user to change their account email", async ({ assert, client, route, cleanup, }) => { const { mails } = await mail.fake(); cleanup(() => mail.restore()); const user = await UserFactory.create(); const response = await client .put(route("settings.account.email")) .withCsrfToken() .header("Referer", route("settings.account")) .loginAs(user) .form({ email: "anewemail@test.com", password: "something", }) .redirects(0); response.assertStatus(302); response.assertHeader("Location", route("settings.account")); response.assertFlashMessage("success", "Your email has been updated"); });Copied!
Next, we want to verify the impact of our request to ensure it actually updated our user's email and stored the change within our email history.
test("allow a user to change their account email", async ({ assert, client, route, cleanup, }) => { const { mails } = await mail.fake(); cleanup(() => mail.restore()); const user = await UserFactory.create(); const response = await client .put(route("settings.account.email")) .withCsrfToken() .header("Referer", route("settings.account")) .loginAs(user) .form({ email: "anewemail@test.com", password: "something", }) .redirects(0); response.assertStatus(302); response.assertHeader("Location", route("settings.account")); response.assertFlashMessage("success", "Your email has been updated"); const updatedUser = await User.findOrFail(user.id); const history = await user.related("emailHistories").query().firstOrFail(); assert.equal(updatedUser.email, "anewemail@test.com"); assert.equal(history.emailOld, user.email); assert.equal(history.emailNew, updatedUser.email); });Copied!
Note, if you don't need access to your old user data, you could also just refresh the data via await user.refresh().
Lastly, we can confirm our email was sent, but we have an important distinction here. Previously, when we've tested email sends, those were sent with await mail.send(). This email, however, is sent with await mail.sendLater(), meaning it has been queued to send but hasn't actually sent as part of this request. Instead, it'll send slightly later at the discretion of our queue.
So, rather than asserting against a sent email, we instead want to assert against a queued email.
test("allow a user to change their account email", async ({ assert, client, route, cleanup, }) => { const { mails } = await mail.fake(); cleanup(() => mail.restore()); const user = await UserFactory.create(); const response = await client .put(route("settings.account.email")) .withCsrfToken() .header("Referer", route("settings.account")) .loginAs(user) .form({ email: "anewemail@test.com", password: "something", }) .redirects(0); response.assertStatus(302); response.assertHeader("Location", route("settings.account")); response.assertFlashMessage("success", "Your email has been updated"); const updatedUser = await User.findOrFail(user.id); const history = await user.related("emailHistories").query().firstOrFail(); assert.equal(updatedUser.email, "anewemail@test.com"); assert.equal(history.emailOld, user.email); assert.equal(history.emailNew, updatedUser.email); // alternative syntax // mails.assertQueued(EmailChangedNotification, ({ message }) => { // return message.hasTo(user.email) // }) const queued = mails.queued( (send) => send instanceof EmailChangedNotification )[0]; queued.message.assertTo(user.email); queued.message.assertSubject("Your email has been successfully changed"); });Copied!
Perfect, next we have at least three sad paths to test.
It should not allow a user to change their email if the password is incorrect
It should not allow an unauthenticated user to change their email
Let's focus first on the incorrect password. For this, we'll have the same setup as before, though we will want to send an invalid password.
test("not allow a user to change their email if the password is incorrect", async ({ assert, client, route, cleanup, }) => { const { mails } = await mail.fake(); cleanup(() => mail.restore()); const user = await UserFactory.create(); const response = await client .put(route("settings.account.email")) .withCsrfToken() .header("Referer", route("settings.account")) .loginAs(user) .form({ email: "anewemail@test.com", password: "Invalid!Password", }) .redirects(0); });Copied!
Our response assertions are relatively similar, except we're now expecting an invalid user credentials error.
test("not allow a user to change their email if the password is incorrect", async ({ assert, client, route, cleanup, }) => { const { mails } = await mail.fake(); cleanup(() => mail.restore()); const user = await UserFactory.create(); const response = await client .put(route("settings.account.email")) .withCsrfToken() .header("Referer", route("settings.account")) .loginAs(user) .form({ email: "anewemail@test.com", password: "Invalid!Password", }) .redirects(0); response.assertStatus(302); response.assertHeader("Location", route("settings.account")); response.assertFlashMessage( "errorsBag.E_INVALID_CREDENTIALS", "Invalid user credentials" ); });Copied!
Then, we want to assert that the user was not changed at all.
test("not allow a user to change their email if the password is incorrect", async ({ assert, client, route, cleanup, }) => { const { mails } = await mail.fake(); cleanup(() => mail.restore()); const user = await UserFactory.create(); const response = await client .put(route("settings.account.email")) .withCsrfToken() .header("Referer", route("settings.account")) .loginAs(user) .form({ email: "anewemail@test.com", password: "Invalid!Password", }) .redirects(0); response.assertStatus(302); response.assertHeader("Location", route("settings.account")); response.assertFlashMessage( "errorsBag.E_INVALID_CREDENTIALS", "Invalid user credentials" ); const updatedUser = await User.findOrFail(user.id); const history = await user.related("emailHistories").query().first(); assert.equal(user.email, updatedUser.email); assert.isNull(history); });Copied!
Finally, we want to verify that an email was not queued up.
test("not allow a user to change their email if the password is incorrect", async ({ assert, client, route, cleanup, }) => { const { mails } = await mail.fake(); cleanup(() => mail.restore()); const user = await UserFactory.create(); const response = await client .put(route("settings.account.email")) .withCsrfToken() .header("Referer", route("settings.account")) .loginAs(user) .form({ email: "anewemail@test.com", password: "Invalid!Password", }) .redirects(0); response.assertStatus(302); response.assertHeader("Location", route("settings.account")); response.assertFlashMessage( "errorsBag.E_INVALID_CREDENTIALS", "Invalid user credentials" ); const updatedUser = await User.findOrFail(user.id); const history = await user.related("emailHistories").query().first(); assert.equal(user.email, updatedUser.email); assert.isNull(history); mails.assertNoneQueued(); });Copied!
Fantastic, our next sad path needs to ensure this requires authentication and redirects unauthenticated users to the login page.
test("not allow an unauthenticated user to change their email", async ({ client, route, cleanup, }) => { const { mails } = await mail.fake(); cleanup(() => mail.restore()); const response = await client .put(route("settings.account.email")) .withCsrfToken() .header("Referer", route("settings.account")) .form({ email: "anewemail@test.com", password: "something", }); response.assertOk(); response.assertTextIncludes("Unauthorized access"); response.assertRedirectsTo(route("auth.login.show")); mails.assertNoneQueued(); });Copied!
Next, we want to test our validation. The password is straightforward enough and captured via our sad path test, so we'll focus on the email, which:
Should require a valid email address
Should require a unique email not already in use
Let's start with the valid email check.
test("require a valid email to change their email to", async ({ client, route, cleanup, }) => { await mail.fake(); cleanup(() => mail.restore()); const user = await UserFactory.create(); const response = await client .put(route("settings.account.email")) .withCsrfToken() .header("Referer", route("settings.account")) .loginAs(user) .form({ email: "@notarealemail.com", password: "something", }) .redirects(0); response.assertStatus(302); response.assertHeader("Location", route("settings.account")); response.assertFlashMessage("errors.email", [ "The email field must be a valid email address", ]); });Copied!
Finally, we need to ensure it requires a unique email that hasn't already been taken.
test("require a unique email to change their email to", async ({ client, route, cleanup, }) => { await mail.fake(); cleanup(() => mail.restore()); const user = await UserFactory.create(); const existingUser = await UserFactory.create(); const response = await client .put(route("settings.account.email")) .withCsrfToken() .header("Referer", route("settings.account")) .loginAs(user) .form({ email: existingUser.email, password: "something", }) .redirects(0); response.assertStatus(302); response.assertHeader("Location", route("settings.account")); response.assertFlashMessage("errors.email", [ "The email has already been taken", ]); });Copied!
Perfect! Hopefully, you're now feeling comfortable with Japa and confident enough to take on testing yourself. Remember not to chase 100% test coverage (where every little thing is tested), but rather focus on testing critical components and where tests provide comfort and confidence in your code.
In the next bonus module, we'll touch quickly on browser tests if you're interested in that. Browser tests allow you to run tests inside an actual browser, giving powerful assertion tools, such as being able to assert specific elements.