概要
いつぞやに書いた記事の続きです。
ログイン関連の処理のテストコードを書く際にも色々とハマったので、備忘録として残しておきます。
また、ログイン処理自体についての解説はこちらに記載しているので、よろしければ。
間違い等ございましたら、やさしく教えて頂けるとうれしいです(╹◡╹)
ゴール
この記事を読むことで、(きっと)以下のことが分かるようになります。
- ログイン処理のテストコードの書き方
- ログインした状態のつくり方
- ログインが必要な画面のテストコードの書き方
- ユーザ登録処理のテストコードの書き方
環境・コード
環境は以下となります。依存ライブラリ等は前回の記事と同一です。
また、ソースコードはGitHubに置いてあります。
全体像
ログインアプリは、以下の機能を持つシンプルなものです。
- ログイン処理
- 権限ごとのトップ画面(管理者・ユーザ)
- ユーザ登録処理
これをざっくりと図で表すと図1のようになります。
図1. 処理フロー
パッケージ構造
機能自体はシンプルなものですが、あれやこれやを考慮していると、パッケージが増えていき、全体像が掴みづらくなってしまうので、役割単位でふんわりと分類したものを図2に示します。
図2. パッケージ全体像
テストしたいこと
コード化するにしろ、しないにしろ、テストを行う上で最も意識しておく必要があるのは、「何を検証したいか」を明確にすることです。
今回は、SpringSecurityへ定義した機能が想定した通りに動作しているか
ということになります。
これでは堅苦しくて少し雰囲気が掴みづらいので、もう少しとっつきやすい形にしてしまいましょう。
図3のように、SpringSecurityさんが頼んだ通りに頑張ってくれているか
とすると、イメージがしやすくなるかと思います。
図3. SpringSecurityの検証対象
字が汚いので重要なことなので、上図をリストでも表しておきましょう。
- 知らない人は通さないか(認証)
- 知ってる人は通してくれるか(認証)
- ユーザはユーザページへ案内してくれるか(認可)
- 管理者は管理者ページへ案内してくれるか(認可)
以下では、これらが正しく動作しているかを検証するためのテストコードの書き方について見ていきます。
Part1. ログイン処理
まずはシンプルにログイン画面から始めていきます。
画面のイメージは、図4のようになります。
図4. ログイン画面
ログイン処理が正しく動作できているかを検証するためには、以下をテストする必要があります。
- DBに存在するユーザであれば、ログインできる
- DBに存在するユーザだが、パスワードが間違っている場合、ログインできない
- そもそもユーザがDBに存在しない場合、ログインできない
これは、言い換えると、SpringSecurityが想定通りの人だけを通してくれるのか
と表すことができます。
それでは、実際にSpringSecurityさんが仕事をしてくれるか、検証していきます。
知ってる人は通してくれる
まずは、単純なケースとして、DB上に存在するユーザでログイン処理を行うと、ログインができるかを検証します。
SpringSecurityは、ログイン処理として、以下を実行します。
- ログインリクエストが送信されると、ユーザ名をキーにDBのユーザテーブルを検索
- 結果が空でない場合、DBから取得したユーザに紐づくパスワードを復号化したものが入力値と一致するか検査
- 一致した場合、SuccessHandlerを呼び出す
処理自体のコードの解説は別記事で行なっているので、ここでは割愛し、早速テストコードを見ていきます。
テストコード
@DbUnitConfiguration(dataSetLoader = CsvDataSetLoader.class)
@TestExecutionListeners({
DependencyInjectionTestExecutionListener.class,
TransactionalTestExecutionListener.class,
DbUnitTestExecutionListener.class,
})
@AutoConfigureMockMvc
@SpringBootTest(classes = {LoginUtApplication.class})
@Transactional
public class LoginControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
@DatabaseSetup(value = "/controller/top/setUp/")
void DB上に存在する利用者ユーザでログインできる() throws Exception {
this.mockMvc.perform(formLogin("/sign_in")
.user("top_user")
.password("password"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/top_user/init"));
}
}
解説
テストコードの書き方自体は前回の記事でざっくりと書いてきたので、ここでは、新しく登場した要素について触れていきます。
formLogin
mockMvcのperfromメソッド
へ渡す引数として、formLogin
なるものが指定されています。
ログイン処理はお馴染みのものなので、雰囲気で動作はイメージできるかと思いますが、ここで大事なのは、以下のことです。
- 引数にはSpringSecurityのConfigクラスのloginProcessingUrlを指定
- CSRFトークンはformLoginメソッドによって自動的に指定される
ログイン処理がある程度理解できていれば、特につまずくことはないかと思います。
リダイレクト処理
公式さんに書かれている通り、SpringSecurityは、ログインの成功/失敗時に指定したURLへリダイレクトさせるよう動作します。
よって、ここでは、リダイレクト先のURLが想定通りかを検証します。
後ほど触れていきますが、ここでは、ログインが上手くいったらユーザのトップ画面っぽいURLへ飛ばされるんだなー、ぐらいを理解して頂けたら十分です。
知らない人は通さない
上記のテストコードで、何やら、SpringSecurityはユーザを通してくれることが分かりました。
しかし、これだけでは、もしかしたら誰でもウェルカムながばがばセキュリティである可能性が残ってしまっています。
ここでは、セキュリティが担保できている、すなわち、通して欲しくない人を通さないか
を検証します。
以下では、対象のテストコードを抜粋して記載します。
テストコード
@Test
@DatabaseSetup(value = "/controller/top/setUp/")
void DB上に存在するユーザでパスワードを間違えると失敗画面へリダイレクトされる() throws Exception {
this.mockMvc.perform(formLogin("/sign_in")
.user("top_user")
.password("wrongpassword"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/?error"));
}
@Test
@DatabaseSetup(value = "/controller/top/setUp/")
void DB上に存在しないユーザでログインするとエラーURLへリダイレクトされる() throws Exception {
this.mockMvc.perform(formLogin("/sign_in")
.user("nobody")
.password("password"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/?error"));
}
解説
正常系と大きな違いはないのですが、リダイレクト先が/?error
となっていることがポイントです。これが正しく動作していれば、ログイン可能なユーザがログインできることだけでなく、ログインできないはずのユーザがログインに失敗することが検証できます。
これで最低限のレベルではありますが、ログイン処理についてテストコードを書くことができました。DaoやService層については、前回の記事と重なるものなので、割愛させて頂いています。興味がございましたら、ソースコードをご参照頂ければと思います。
さて、Webアプリケーションはログインして終わり...ではなく、ログインした状態で様々な画面を利用していきます。しかし、ログインが必要な画面のテストコードに毎回ログインして、それから画面のテストコードを書いて...としてしまうと、非常に面倒です。
これを解決するため、ログイン済みの状態
を手動で作ることができれば、とても便利です。
それでは、ログイン後の画面のテストコードを見ていく前に、ログイン済みの状態のつくり方に触れていきます。
Part1.5 ログインした状態のつくり方
さて、ログインした状態
をつくれば良い、と書きましたが、具体的には何をすればよいのでしょうか。
前処理として、セッションIDを発行して、紐づくユーザオブジェクトを作成しておき、SpringSecurityが扱える形にあれこれして...とSpringSecurityが行なっている処理を一つ一つ再現しても作ることはできますが、ちょっとしんどそうです。
実際には、公式で紹介されている通り、アノテーションで処理をちょろっと書くだけでログイン済みの状態を擬似的に作成することができます。
ここで、ログインした状態
は、正確には、SecurityContext
と呼ばれています。コンテキストについての説明は、ここが分かりやすいかと思います。
急に話が抽象的になってしまいました。
もう少しイメージしやすくするため、コンテキストを簡単に図で表したものを図5に示します。
図5. セキュリティコンテキスト
コンテキストがあることによって、画面でかちかちログインしたときと同じ状態をコードによってつくり出す
ことができます。便利ですね。
実際にコンテキストをつくる為のコードを見ていきます。
ログイン済みユーザ(仮)
まずは、中身に入る前に、テストコードの中でどのようにログイン済みの状態が記述されているか、見てみましょう。
これから触れていくコードを書いておくことによって、どのようにテストコードが書きやすくなるか、メリットを先に知っておくと、理解もしやすくなるかと思います。
@Test
@DatabaseSetup(value = "/controller/top/setUp/")
@WithMockCustomUser(username="top_user", password="password")
void init処理でviewとしてユーザトップが渡される() throws Exception {
this.mockMvc.perform(get("/top_user/init"))
.andExpect(view().name("top_user"));
}
重要なのは、WithMockCustomUser
アノテーションです。パラメータとして、ユーザ名・パスワードを渡す。たったこれだけでログイン済みユーザとして、ログインが必要な画面のテストコードを実行することができます。ありがてえ。
さてさて、このアノテーションはカスタムアノテーションで、作るのに少し頑張らないといけないものですが、一度つくってしまえば、ログインが必要なアプリケーションで使い回すことができます。楽をするために頑張って見てみましょう。
アノテーションのコード
まずは先ほど書かれていた、WithMockCustomUser
アノテーションのコードです。
カスタムアノテーションについては、こちらをば。
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
String username();
String password();
}
解説
アノテーション自体は、2つのフィールドを持つシンプルなものなのですが、WithSecurityContext
なるすごく長いパラメータを持つアノテーションが難しそうな雰囲気を漂わせています。
これは、SpringSecurityContext
を定義するためのアノテーションです。いまいちピンとこないので、言い方を変えると、SpringSecurityさんに、あらかじめ「この人は覚えておいてね!!」という情報を覚えてもらうためのメモのようなものです。
WithMockCustomUserでは、名前の通り、ユーザ情報しか定義していないので、続いて、コンテキストを作成している処理を見てみます。
コンテキストの工場
コンテキストの工場と言われてもなんのことやらさっぱりなので、実際のコードを見てみることにしましょう。
public class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory<WithMockCustomUser>{
@Autowired
private AuthenticationManager authenticationManager;
@Override
public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
// ユーザ名・パスワードで認証するためのトークンを発行
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(customUser.username(), customUser.password());
// ログイン処理
Authentication auth = authenticationManager.authenticate(authToken);
context.setAuthentication(auth);
return context;
}
}
解説
色々とコードは書いてありますが、やっていることはごくシンプルで、ログイン済みの状態
すなわちSecurityContextを作っています。
個々の処理としては大まかに以下のことをおこなっています。
- SecurityContextHolderをもとに空のSecurityContextを生成
- ユーザ名・パスワードによる認証トークンを生成
- トークンをもとに認証処理を行い、認証情報を格納したオブジェクトをコンテキストに設定
SecurityContextHolderは、コンテキストを管理するためのオブジェクトで、スレッド周りのあれこれを管理していたりするのですが、本筋から外れてしまうので、今回は割愛致します。
コンテキストを作る大元なんだなーぐらいに思って頂ければ問題ないかと思います。
そして、大事なこととして、ここでの認証トークン
は、クッキーとよく対比されたり、APIトークンなどと呼ばれるものとは全くの別物だということです。
これは、ユーザ名・パスワードが認証のための鍵となる、という意味から、「Hard Token」の方が意味的に近いかと思われます。
少し話がそれてしまいましたが、カスタムアノテーションを定義することにより、テストコード上では、ユーザ名・パスワードを指定するだけで簡単にログイン済みの状態、すなわち、SecurityContext
を作れました。やったね。
Part2. 利用画面
少し大変でしたが、ログイン済みの状態が作れるようになったので、ログインが必要な画面のテストコードはぐっと楽に書けるようになります。
さて、ログインが必要な画面の例として、ユーザ用トップ画面を見てみます。
画面イメージを図6に示します。
図6. ユーザ用トップ画面
ユーザに挨拶しているだけの単純な画面です。
サンプル用の画面なので、デザインは...気にしないでください。
さて、今回はログイン関連の処理を検証することが目的なので、この画面については、以下のことが想定した通りに動作しているかを検証していきます。
- ログイン済みユーザの場合、画面遷移が可能か
- 未ログインユーザの場合、画面遷移ができないか
- 管理者権限でログインした場合、ユーザ用画面へ遷移できないか
- ログアウト処理が有効か
おおむねログイン画面と同様、悪いことはできないか
を軸に検証すれば良さそうですね。
では、実際のテストコードを見ていきます。
知ってる人を案内してくれるか
ログイン処理と同様、最初は正規ルートでの動作を見ていきます。
先ほど紹介したアノテーションが増えたぐらいなので、すっと理解できるかと思います。
テストコード
@DbUnitConfiguration(dataSetLoader = CsvDataSetLoader.class)
@TestExecutionListeners({
DependencyInjectionTestExecutionListener.class,
TransactionalTestExecutionListener.class,
DbUnitTestExecutionListener.class,
WithSecurityContextTestExecutionListener.class
})
@AutoConfigureMockMvc
@SpringBootTest(classes = {LoginUtApplication.class})
@Transactional
public class TopUserControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
@DatabaseSetup(value = "/controller/top/setUp/")
@WithMockCustomUser(username="top_user", password="password")
void init処理でviewとしてユーザトップが渡される() throws Exception {
this.mockMvc.perform(get("/top_user/init"))
.andExpect(view().name("top_user"));
}
@Test
@DatabaseSetup(value = "/controller/top/setUp/")
@WithMockCustomUser(username="top_user", password="password")
void init処理でログインユーザ名がモデルへ渡される() throws Exception {
this.mockMvc.perform(get("/top_user/init"))
.andExpect(model().attribute("loginUsername", "top_user"));
}
}
解説
WithMockCustomUser
については、これまで触れてきた通りで、ユーザ名・パスワードを指定することで、ログイン済みの状態を作り出しています。
ここで、WithSecurityContextTestExecutionListener
という何やら関連がありそうなクラスがTestExecutionListeners
に指定されていることが分かります。
これは、TestContextManager
がSecurityContext
を扱えるようにするための設定です。
コンテキストのためのコンテキストと書くと少しややこしくなってしまうので、ここでも図で整理してみましょう。
TestContextManagerを図でざっくりと表したものを図7に示します。
図7. TestContextManager
コンテキスト以外にも前処理・後処理等も担っているのですが、ここで重要なのは、テストの実行に必要なコンテキストを管理している
点です。
ぱっとイメージは掴みづらいかとは思いますが、テストを実行する前に事前にログインした状態をつくっておくためには、必要な処理となるので、意識しておくとよいかと思います。
悪だくみを防げるか
続いて、悪いことをたくらんでいる人
を防げるかを見ていきます。
ここで、整理するため、悪いこと
が何を表しているか、もう一度見てみます。
- 未ログインユーザがユーザトップ画面へURLを直接入力して覗く
- 権限を持たないユーザがユーザトップ画面へURLを直接入力して覗く
上記を防ぐための実装自体はSpringSecurityのConfigクラスで定義しているので、ここでは、本当に防げるかどうかをテスト的なリクエストを送信することで
確かめてみます。
テストコード
@Test
@DatabaseSetup(value = "/controller/top/setUp/")
void 未ログインユーザはユーザトップ画面へURL直打ちで遷移できない() throws Exception {
this.mockMvc.perform(get("/top_user/init"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("http://localhost/"));
}
@Test
@DatabaseSetup(value = "/controller/top/setUp/")
@WithMockCustomUser(username="admin_user", password="password")
void 管理者権限のユーザはユーザトップ画面へURL直打ちで遷移できない() throws Exception {
this.mockMvc.perform(get("/top_user/init"))
.andExpect(status().isForbidden());
}
解説
ポイントとなるのは、悪事を防いだらどうするのか、です。
ログインしていないユーザの場合、ログイン画面へリダイレクト
して欲しいので、ステータスコードがリダイレクト(3始まり)であること、遷移先がログイン画面であることを記述しています。
そして、権限を持たないユーザの場合(管理者は見ることができた方が自然っぽいですが...)、ステータスコードとして403Forbiddenが返ってくることを記述しています。
これらのテストコードにより、SpringSecurityさんは想定していた仕事は問題なくこなしてくれていることが分かりました。
最後に後処理として、ログアウトについても、見ておきたいと思います。
ログアウトで後始末ができるか
テストコード
@Test
@DatabaseSetup(value = "/controller/top/setUp/")
@WithMockCustomUser(username="top_user", password="password")
void ログアウト処理でログイン画面へ遷移する() throws Exception {
this.mockMvc.perform(post("/logout")
.with(SecurityMockMvcRequestPostProcessors.csrf()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/?logout"));
}
解説
ログアウト処理も、SpringSecurityで設定したログアウト用のURLへPOSTリクエストを送信しているだけなので、雰囲気で理解できるかと思います。
ただ、formLoginメソッド
と異なり、通常のPOSTリクエストでは、CSRFトークンは付与してくれません。よって、wirh(SecurityMockMvcRequestPostProcessors.csrf)
メソッドでCSRFトークンをリクエストに追加する必要があります。
これだけでも、ログアウト処理が動いていることの検証には問題なさそうですが、もう一歩踏み込んでみましょう。
ログアウトしたということは、ログインしていない状態
へと変化したとも言えます。よって、ログアウト後には、ログインが必要な画面へは、遷移できないことが正しい動作です。
これについても、念のため見ておきたいと思います。
といっても、難しいことはしておらず、単にログアウト後に画面遷移処理を追加してみただけです。
以下の図8, 図9を見て頂ければログアウト処理によってログインしていない状態が作り出されたことが分かります。
図8. ログアウト後はログアウト前でできていたことで失敗する
図9. ログアウト後は再度ログインを求められる
少し長くなってしまいましたが、これでログイン処理についてもテストコードで検証することができました。やったぜ。
まとめに入る前に、ログイン処理とは少しずれてしまいますが、それなりにはまったユーザ登録処理
について、補足として簡単に触れておきたいと思います。
Extra ユーザ登録処理
さてさて、ユーザ登録処理についてですが、処理自体は、SecurityContext
を作ることと、ほぼ同じです。
実装についてはソースコードをば。
ここで取り上げたいのは、バリデーション処理です。
動きのイメージとしては、図10のようなものとなります。
図10. ユーザ登録
バリデーション処理のテストコードの書き方について、色々とはまってしまったので、備忘録としてポイントを書いていきます。
バリデーション
テストコード
@Test
void 半角英数ハイフンアンダースコア以外に属する記号群をPOSTするとAuthInpuTypeエラーが発生() throws Exception {
this.mockMvc.perform(post("/signup/register")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "<script>alert(1)</script>")
.param("rawPassword", ";delete from ut_user")
.with(SecurityMockMvcRequestPostProcessors.csrf()))
.andExpect(model().hasErrors())
.andExpect(model().attributeHasFieldErrorCode("userForm", "username", "AuthInputType"))
.andExpect(model().attributeHasFieldErrorCode("userForm", "rawPassword", "AuthInputType"));
}
@DatabaseSetup(value = "/controller/signup/setUp/")
@Test
void DBに既に存在するユーザ名でPOSTするとUniqueUsernameエラーが発生() throws Exception {
this.mockMvc.perform(post("/signup/register")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "test_user")
.param("rawPassword", "password")
.with(SecurityMockMvcRequestPostProcessors.csrf()))
.andExpect(model().hasErrors())
.andExpect(model().attributeHasFieldErrorCode("userForm", "username", "UniqueUsername"));
}
解説
色々と書いてありますが、やっていることは値を設定してPOSTリクエストを手動で送信しているだけです。ここで、重要なのは、attributeHasFieldErrorCodeメソッド
です。
引数として、name, fieldName, error
を渡しています。
これらは、それぞれ、以下の対応関係を持っています。
- name...フィールドを格納するオブジェクト Formオブジェクトに相当
- fieldName...エラーが存在するFormオブジェクト内のフィールド ユーザ名・パスワードのいずれかが該当
- error...エラーの内容 後述
name, fieldNameについては、MVCアプリを触ったことがあれば、イメージはわくかと思います。errorについては、フィールドを実際に見て頂くとわかるかと思います。
@Data
public class UserForm {
@AuthInputType
@UniqueUsername
private String username;
@AuthInputType
private String rawPassword;
}
ここで、errorとして引数に渡されたものが、アノテーションの名前と対応しています。
指定されているアノテーションは、カスタムアノテーションで、Constraint
アノテーションが付与されているので、制約アノテーション
として振る舞います。
制約違反が存在すると、BindingResult
がエラーとして検知してくれるので、バリデーション処理で扱えるようになります。
詳細については、公式を見て頂くのがよいかと思います。
相関チェックについては理解しきれておらず、パスワード合致のテストコードとかで心が折れてしまったので、お詳しい方がいらっしゃったら、情報を頂けるとうれしいです。(:
まとめ
ログイン処理についても、ざっくりとではありますが、テストコードを記述し、動作が検証できるようになりました。
前回の記事で簡単なCRUD、今回の記事でWebアプリで重要なログイン処理を見ていくことで、簡単なアプリケーションであれば、しっかりテストコードを書きながら開発ができるのではないかと思います。
コードを書いていると、超いい感じのリファクタリングを突然思いついて、コードをあれこれ変えたくなりますが、こうやってテストコードを書いていれば、検証した振る舞いが崩れていないか
はボタン一発で検証できるようになります。
他にも、毎回ログインして、画面をかちかちしてエラー画面を出して...としなくても、効率よく、かつ再現性を担保してテストが実行できるようになります。
テストコードの書き方を習得するにはコストがそれなりにかかってしまいますが、楽しいコードを書く時間が更に増えてくれるので、楽しみながら少しずつ取り入れていくのがよいかなと。
エラそうに言っていますが、私もテストコードに関してはまだまだ勉強中の身なので、頑張ってもっと色々勉強したいです。