かべブログ

TypeScript, React, Goなどでまったり個人開発。たまに競プロ。

テスト駆動開発を実践しながら Rust で xUnit を書いてみる(2)

前回 => テスト駆動開発を実践しながら Rust で xUnit を書いてみる(1)

はじめに

前回の続きで、20章と21章をやっていきます。今回は teardown とテスト実行数の話題です。

実装

後片付け(20章)

teardown を実装します。その前に、setup -> メソッド -> teardown の順番で実行されているか確認したいので、実行ログを WasRun 内で保持することにします。テストはこんな感じでしょうか。

impl TestCaseTest {
    fn test_running(&mut self) {
        self.test.run();
        assert!(self.test.was_run);
    }
    fn test_setup(&mut self) {
        self.test.run();
        assert_eq!(self.test.log, String::from("setup "));
    }
}

WasRun を定義するところを全て変えていきます。

struct WasRun {
    was_run: bool,
    log: String,
    name: String,
}

impl TestCase for WasRun {
    fn new(name: String) -> WasRun {
        WasRun {
            was_run: false,
            log: String::from(""),
            name,
        }
    }

    fn run(&mut self) {
        self.setup();
        match self.name.as_str() {
            "test_method" => self.test_method(),
            _ => (),
        }
    }
}

impl TestCase for TestCaseTest {
    fn new(name: String) -> TestCaseTest {
        TestCaseTest {
            name,
            test: WasRun {
                was_run: false,
                log: String::from(""),
                name: String::from("test_method"),
            },
        }
    }

    fn run(&mut self) {
        match self.name.as_str() {
            "test_running" => self.test_running(),
            "test_setup" => self.test_setup(),
            _ => (),
        }
    }
}

最後に setup 関数をフラグからログを使う方式に変えて完成です。

impl WasRun {
    fn setup(&mut self) {
        self.log += "setup ";
    }

    fn test_method(&mut self) {
        self.was_run = true;
    }
}

メソッドが走ったかどうかを確かめるフラグもログに置き換えます。こちらもテストから書きます。

impl TestCaseTest {
    fn test_running(&mut self) {
        self.test.run();
        assert!(self.test.was_run);
    }
    fn test_setup(&mut self) {
        self.test.run();
        assert_eq!(self.test.log, String::from("setup test_method "));
    }
}

メソッドの動作を変えます。

impl WasRun {
    fn setup(&mut self) {
        self.log += "setup ";
    }

    fn test_method(&mut self) {
        self.log += "test_method ";
    }
}

test_running 周りがいらなくなるのでそこに関連する部分をごっそり消して完成です。残された test_setup は名前が不適当になったので test_template_method にしておきます。メソッド名が変えたことによる match 式部分の変え忘れが怖いので、どれにもマッチしなかった時は雑ですがパニックするようにしておきます。

ここまでで書いたコードは次のようになります。

trait TestCase {
    fn new(name: String) -> Self;
    fn run(&mut self);
}

struct WasRun {
    log: String,
    name: String,
}

impl TestCase for WasRun {
    fn new(name: String) -> WasRun {
        WasRun {
            log: String::from(""),
            name,
        }
    }

    fn run(&mut self) {
        self.setup();
        match self.name.as_str() {
            "test_method" => self.test_method(),
            _ => unreachable!("no match"),
        }
    }
}

impl WasRun {
    fn setup(&mut self) {
        self.log += "setup ";
    }

    fn test_method(&mut self) {
        self.log += "test_method ";
    }
}

struct TestCaseTest {
    name: String,
    test: WasRun,
}

impl TestCase for TestCaseTest {
    fn new(name: String) -> TestCaseTest {
        TestCaseTest {
            name,
            test: WasRun {
                log: String::from(""),
                name: String::from("test_method"),
            },
        }
    }

    fn run(&mut self) {
        match self.name.as_str() {
            "test_template_method" => self.test_template_method(),
            _ => unreachable!("no match"),
        }
    }
}

impl TestCaseTest {
    fn test_template_method(&mut self) {
        self.test.run();
        assert_eq!(self.test.log, String::from("setup test_method "));
    }
}

fn main() {
    TestCaseTest::new(String::from("test_template_method")).run();
}

やっと teardown のテストが書けます。

impl TestCaseTest {
    fn test_template_method(&mut self) {
        self.test.run();
        assert_eq!(self.test.log, String::from("setup test_method teardown "));
    }
}

teardown を実装し、run の最後に実行するようにして終了です。

impl TestCase for WasRun {
    fn new(name: String) -> WasRun {
        WasRun {
            log: String::from(""),
            name,
        }
    }

    fn run(&mut self) {
        self.setup();
        match self.name.as_str() {
            "test_method" => self.test_method(),
            _ => unreachable!("no match"),
        }
        self.teardown();
    }
}

impl WasRun {
    fn setup(&mut self) {
        self.log += "setup ";
    }

    fn test_method(&mut self) {
        self.log += "test_method ";
    }

    fn teardown(&mut self) {
        self.log += "teardown ";
    }
}

ちょっとしたリファクタリングとして、使っている TestCaseTest が1つになり共通の WasRun を持つ必要がなくなったので消してしまいます。

ここまでで20章の内容は終了です。コードは現在このようになっています。

trait TestCase {
    fn new(name: String) -> Self;
    fn run(&mut self);
}

struct WasRun {
    log: String,
    name: String,
}

impl TestCase for WasRun {
    fn new(name: String) -> WasRun {
        WasRun {
            log: String::from(""),
            name,
        }
    }

    fn run(&mut self) {
        self.setup();
        match self.name.as_str() {
            "test_method" => self.test_method(),
            _ => unreachable!("no match"),
        }
        self.teardown();
    }
}

impl WasRun {
    fn setup(&mut self) {
        self.log += "setup ";
    }

    fn test_method(&mut self) {
        self.log += "test_method ";
    }

    fn teardown(&mut self) {
        self.log += "teardown ";
    }
}

struct TestCaseTest {
    name: String,
}

impl TestCase for TestCaseTest {
    fn new(name: String) -> TestCaseTest {
        TestCaseTest { name }
    }

    fn run(&mut self) {
        match self.name.as_str() {
            "test_template_method" => self.test_template_method(),
            _ => unreachable!("no match"),
        }
    }
}

impl TestCaseTest {
    fn test_template_method(&self) {
        let mut test = WasRun {
            log: String::from(""),
            name: String::from("test_method"),
        };
        test.run();
        assert_eq!(test.log, String::from("setup test_method teardown "));
    }
}

fn main() {
    TestCaseTest::new(String::from("test_template_method")).run();
}

数え上げ(21章)

複数のテストの実行結果を表示します。まずはべた書きでテストと実装を書いてしまいましょう。

impl TestCaseTest {
    fn test_template_method(&self) {
        let mut test = WasRun {
            log: String::from(""),
            name: String::from("test_method"),
        };
        test.run();
        assert_eq!(test.log, String::from("setup test_method teardown "));
    }

    fn test_result(&self) {
        let mut test = WasRun {
            log: String::from(""),
            name: String::from("test_method"),
        };
        let result = test.run();
        assert_eq!(result.summary(), String::from("1 run, 0 failed"));
    }
}

fn main() {
    TestCaseTest::new(String::from("test_template_method")).run();
    TestCaseTest::new(String::from("test_result")).run();
}

TestCase トレイトを実装しているものには run の度に何かしらの結果を返して欲しいので、新しく TestResult という struct を用意してそれを返してもらうようにします。また TestResultsummary というメソッドが必要です。さらに runTestResult を返すことをコードにも教えます。

struct TestResult {}

impl TestResult {
    fn summary(&self) -> String {
        String::from("1 run, 0 failed")
    }
}

trait TestCase {
    fn new(name: String) -> Self;
    fn run(&mut self) -> TestResult;
}

対応する部分を書き換えたらひとまず出来上がりです。

impl TestCase for WasRun {
    fn new(name: String) -> WasRun {
        WasRun {
            log: String::from(""),
            name,
        }
    }

    fn run(&mut self) -> TestResult {
        self.setup();
        match self.name.as_str() {
            "test_method" => self.test_method(),
            _ => unreachable!("no match"),
        }
        self.teardown();

        TestResult {}
    }
}

impl TestCase for TestCaseTest {
    fn new(name: String) -> TestCaseTest {
        TestCaseTest { name }
    }

    fn run(&mut self) -> TestResult {
        match self.name.as_str() {
            "test_template_method" => self.test_template_method(),
            "test_result" => self.test_result(),
            _ => unreachable!("no match"),
        }

        TestResult {}
    }
}

べた書きの結果を直していきます。TestResult が実行数を持てるようにしましょう。さらにテスト結果内の実行数をここから取るようにします。

struct TestResult {
    run_count: i16,
}

impl TestResult {
    fn summary(&self) -> String {
        format!("{} run, 0 failed", self.run_count)
    }
}

このカウントを増やす処理を行っていないのでテストが落ちます。run の度に TestResult を用意して実行時にカウントを増やす処理を行うと上手く行きます。

impl TestResult {
    fn summary(&self) -> String {
        format!("{} run, 0 failed", self.run_count)
    }

    fn test_started(&mut self) {
        self.run_count += 1;
    } 
}

impl TestCase for WasRun {
    fn new(name: String) -> WasRun {
        WasRun {
            log: String::from(""),
            name,
        }
    }

    fn run(&mut self) -> TestResult {
        self.setup();
        let mut result = TestResult { run_count: 0 };
        result.test_started();
        match self.name.as_str() {
            "test_method" => self.test_method(),
            _ => unreachable!("no match"),
        }
        self.teardown();

        result
    }
}

impl TestCase for TestCaseTest {
    fn new(name: String) -> TestCaseTest {
        TestCaseTest { name }
    }

    fn run(&mut self) -> TestResult {
        let mut result = TestResult { run_count: 0 };
        result.test_started();

        match self.name.as_str() {
            "test_template_method" => self.test_template_method(),
            "test_result" => self.test_result(),
            _ => unreachable!("no match"),
        }

        result
    }
}

ここでそれぞれ実装している run メソッドは両方 setup -> TestResult の用意と起動 -> match での実行メソッド分岐 (Python での getattr) -> teardown -> TestResult の返却(setupteardown は何もしない可能性あり)という構造になっているので、この流れを踏まえてトレイトに引き上げます。

trait TestCase {
    fn new(name: String) -> Self;
    fn setup(&mut self);
    fn teardown(&mut self);
    fn getattr(&mut self);
    fn run(&mut self) -> TestResult {
        self.setup();
        let mut result = TestResult { run_count: 0 };
        result.test_started();
        self.getattr();
        self.teardown();

        result
    }
}

impl TestCase for WasRun {
    fn new(name: String) -> WasRun {
        WasRun {
            log: String::from(""),
            name,
        }
    }

    fn setup(&mut self) {
        self.log += "setup ";
    }

    fn teardown(&mut self) {
        self.log += "teardown ";
    }

    fn getattr(&mut self) {
        match self.name.as_str() {
            "test_method" => self.test_method(),
            _ => unreachable!("no match"),
        }
    }
}

impl TestCase for TestCaseTest {
    fn new(name: String) -> TestCaseTest {
        TestCaseTest { name }
    }

    fn setup(&mut self) {}

    fn teardown(&mut self) {}

    fn getattr(&mut self) {
        match self.name.as_str() {
            "test_template_method" => self.test_template_method(),
            "test_result" => self.test_result(),
            _ => unreachable!("no match"),
        }
    }
}

テストが壊れている時も確認したいので、テストを追加します。

impl TestCaseTest {
    fn test_failed_result(&self) {
        let mut test = WasRun {
            log: String::from(""),
            name: String::from("broken_method")
        };
        let result = test.run();
        assert_eq!(result.summary(), String::from("1 run, 1 failed"));
    }
}

fn main() {
    TestCaseTest::new(String::from("test_template_method")).run();
    TestCaseTest::new(String::from("test_result")).run();
    TestCaseTest::new(String::from("test_failed_result")).run();
}

getattr の修正と壊れている必ずパニックするメソッドの追加を行います。

impl WasRun {
    fn test_method(&mut self) {
        self.log += "test_method ";
    }

    fn broken_method(&mut self) {
        panic!("panic");
    }
}

この詳しい実装は次章になるので、一度対応するテストをコメントアウトしてここは終了です。コード全体は以下のようになっています。

struct TestResult {
    run_count: i16,
}

impl TestResult {
    fn summary(&self) -> String {
        format!("{} run, 0 failed", self.run_count)
    }

    fn test_started(&mut self) {
        self.run_count += 1;
    }
}

trait TestCase {
    fn new(name: String) -> Self;
    fn setup(&mut self);
    fn teardown(&mut self);
    fn getattr(&mut self);
    fn run(&mut self) -> TestResult {
        self.setup();
        let mut result = TestResult { run_count: 0 };
        result.test_started();
        self.getattr();
        self.teardown();

        result
    }
}

struct WasRun {
    log: String,
    name: String,
}

impl TestCase for WasRun {
    fn new(name: String) -> WasRun {
        WasRun {
            log: String::from(""),
            name,
        }
    }

    fn setup(&mut self) {
        self.log += "setup ";
    }

    fn teardown(&mut self) {
        self.log += "teardown ";
    }

    fn getattr(&mut self) {
        match self.name.as_str() {
            "test_method" => self.test_method(),
            "broken_method" => self.broken_method(),
            _ => unreachable!("no match in WasRun"),
        }
    }
}

impl WasRun {
    fn test_method(&mut self) {
        self.log += "test_method ";
    }

    fn broken_method(&mut self) {
        panic!("panic");
    }
}

struct TestCaseTest {
    name: String,
}

impl TestCase for TestCaseTest {
    fn new(name: String) -> TestCaseTest {
        TestCaseTest { name }
    }

    fn setup(&mut self) {}

    fn teardown(&mut self) {}

    fn getattr(&mut self) {
        match self.name.as_str() {
            "test_template_method" => self.test_template_method(),
            "test_result" => self.test_result(),
            "test_failed_result" => self.test_failed_result(),
            _ => unreachable!("no match in TestCaseTest"),
        }
    }
}

impl TestCaseTest {
    fn test_template_method(&self) {
        let mut test = WasRun {
            log: String::from(""),
            name: String::from("test_method"),
        };
        test.run();
        assert_eq!(test.log, String::from("setup test_method teardown "));
    }

    fn test_result(&self) {
        let mut test = WasRun {
            log: String::from(""),
            name: String::from("test_method"),
        };
        let result = test.run();
        assert_eq!(result.summary(), String::from("1 run, 0 failed"));
    }

    fn test_failed_result(&self) {
        let mut test = WasRun {
            log: String::from(""),
            name: String::from("broken_method")
        };
        let result = test.run();
        assert_eq!(result.summary(), String::from("1 run, 1 failed"));
    }
}

fn main() {
    TestCaseTest::new(String::from("test_template_method")).run();
    TestCaseTest::new(String::from("test_result")).run();
    // TestCaseTest::new(String::from("test_failed_result")).run();
}

終わりに

少しずつ難しくなってきましたが、試行錯誤の工程などもなかなか楽しめています。

参考文献

テスト駆動開発を実践しながら Rust で xUnit を書いてみる(1)

はじめに

言わずと知れたテスト駆動開発を今更読んでいたところ、筆者が「新しい言語に触れるときは xUnit の実装から始めてみる」という旨のことを書いていたので、最近触れている Rust の勉強と TDD の練習を兼ねてやってみることにしました。「テスト駆動開発」の第2部を読みながらご覧ください。

開発環境

  • cargo, rustc 1.66.0

実装

xUnit へ向かう小さな一歩(18章)

まずやりたいのは以下のようなテストです。

  1. 構造体を初期化
  2. メソッドがまだ走っていないことを確認
  3. メソッドを走らせる
  4. メソッドが走ったことを確認

Rust で単純に書いてみるとこんな感じでしょうか。

fn main() {
    let mut test = WasRun::new(String::from("test_method"));
    assert!(!test.was_run);
    test.run();
    assert!(test.was_run);
}

当然 WasRun が無いと怒られるので、足します。また WasRun::new() で初期化したいのでそれも実装してあげます。

struct WasRun {
    was_run: bool,
}

impl WasRun {
    fn new(name: String) -> WasRun {
        WasRun { was_run: false }
    }
}

最後に test_method とそれを呼ぶ run を実装して出来上がり、、

impl WasRun {
    fn new(name: String) -> WasRun {
        WasRun { was_run: false }
    }

    fn test_method(&self) {
        self.was_run = true;
    }

    fn run(&self) {
        self.test_method();
    }
}

と思いきや test.test_method() で怒られています。test の中身を変えているので let mut で定義する必要がありました。これを解決すると次のようになり完成です。cargo run も通ることを確かめてみてください。

struct WasRun {
    was_run: bool,
}

impl WasRun {
    fn new(name: String) -> WasRun {
        WasRun { was_run: false }
    }

    fn test_method(&mut self) {
        self.was_run = true;
    }

    fn run(&mut self) {
        self.test_method();
    }
}

fn main() {
    let mut test = WasRun::new(String::from("test_method"));
    assert!(!test.was_run);
    test.run();
    assert!(test.was_run);
}

Pythongetattr による動的な呼び出しは Rust で対応する方法が見つからなかったので、少し力技で実装を行います。match 文で対応する関数を呼び出すように変更します。(そもそも実装方針を変えた方が良いかもしれませんが、ここは本準拠で行きます。)

impl WasRun {
    fn new(name: String) -> WasRun {
        WasRun { was_run: false, name }
    }

    fn test_method(&mut self) {
        self.was_run = true;
    }

    fn run(&mut self) {
        match self.name.as_str() {
            "test_method" => self.test_method(),
            _ => (),
        }
    }
}

リファクタリングを行います。newrun メソッドはこれからの実装でもよく使いそうなので分割させたいのですが、Rust ではクラスもなく継承もできないので、トレイトで実現します。

trait TestCase {
    fn new(name: String) -> Self;
    fn run(&mut self);
}

struct WasRun {
    was_run: bool,
    name: String,
}

impl TestCase for WasRun {
    fn new(name: String) -> WasRun {
        WasRun { was_run: false, name }
    }

    fn run(&mut self) {
        match self.name.as_str() {
            "test_method" => self.test_method(),
            _ => (),
        }
    }
}

impl WasRun {
    fn test_method(&mut self) {
        self.was_run = true;
    }
}

最後にテスト実行用の struct を、さっき実装したトレイトを使って作りましょう。テストの中身をこの struct の中に入れることができます。

struct TestCaseTest {
    name: String,
}

impl TestCase for TestCaseTest {
    fn new(name: String) -> TestCaseTest {
        TestCaseTest { name }
    }

    fn run(&mut self) {
        match self.name.as_str() {
            "test_running" => self.test_running(),
            _ => (),
        }
    }
}

impl TestCaseTest {
    fn test_running(&self) {
        let mut test = WasRun::new(String::from("test_method"));
        assert!(!test.was_run);
        test.run();
        assert!(test.was_run);
    }
}

そうするとテストの実行部分はこのように1行で書くことが出来ます。

fn main() {
    TestCaseTest::new(String::from("test_running")).run();
}

これでこの章の実装は完了です。現在のコードは次のようになっています。

trait TestCase {
    fn new(name: String) -> Self;
    fn run(&mut self);
}

struct WasRun {
    was_run: bool,
    name: String,
}

impl TestCase for WasRun {
    fn new(name: String) -> WasRun {
        WasRun { was_run: false, name }
    }

    fn run(&mut self) {
        match self.name.as_str() {
            "test_method" => self.test_method(),
            _ => (),
        }
    }
}

impl WasRun {
    fn test_method(&mut self) {
        self.was_run = true;
    }
}

struct TestCaseTest {
    name: String,
}

impl TestCase for TestCaseTest {
    fn new(name: String) -> TestCaseTest {
        TestCaseTest { name }
    }

    fn run(&mut self) {
        match self.name.as_str() {
            "test_running" => self.test_running(),
            _ => (),
        }
    }
}

impl TestCaseTest {
    fn test_running(&self) {
        let mut test = WasRun::new(String::from("test_method"));
        assert!(!test.was_run);
        test.run();
        assert!(test.was_run);
    }
}

fn main() {
    TestCaseTest::new(String::from("test_running")).run();
}

前準備(19章)

テスト前に行われる動作 (セットアップ) に関する実装を行います。まずは例のごとくテストから書きます。

impl TestCaseTest {
    fn test_running(&self) {
        let mut test = WasRun::new(String::from("test_method"));
        assert!(!test.was_run);
        test.run();
        assert!(test.was_run);
    }
    fn test_setup(&self) {
        let mut test = WasRun::new(String::from("test_method"));
        test.run();
        assert!(test.was_setup);
    }
}

fn main() {
    TestCaseTest::new(String::from("test_running")).run();
    TestCaseTest::new(String::from("test_setup")).run();
}

必要なフィールドを追加して、was_setup を変更するためのメソッドを追加します。

struct WasRun {
    was_run: bool,
    was_setup: bool,
    name: String,
}

impl WasRun {
    fn setup(&mut self) {
        self.was_setup = true;
    }

    fn test_method(&mut self) {
        self.was_run = true;
    }
}

run の実行時に setup を走らせれば OK です。

impl TestCase for WasRun {
    fn new(name: String) -> WasRun {
        WasRun {
            was_run: false,
            was_setup: false,
            name,
        }
    }

    fn run(&mut self) {
        self.setup();
        match self.name.as_str() {
            "test_method" => self.test_method(),
            _ => (),
        }
    }
}

このあと書籍内ではセットアップの動作は TestCaseTest でも行うということで TestCase に持たせていますが、実装を進めていたところ今回は new で struct を作るときに型付けに従って厳密に作っているため、TestCaseTest で setup を実装するメリットを今のところ感じられませんでした。よって setupTestCase トレイトに乗せることはしません。

またここまでの TestCaseTest 内のメソッドでは同じ let mut test: WasRun が使われているので、これを TestCaseTest の初期化に組み込みます。

struct TestCaseTest {
    name: String,
    test: WasRun,
}

impl TestCase for TestCaseTest {
    fn new(name: String) -> TestCaseTest {
        TestCaseTest {
            name,
            test: WasRun {
                was_run: false,
                was_setup: false,
                name: String::from("test_method"),
            },
        }
    }

    fn run(&mut self) {
        match self.name.as_str() {
            "test_running" => self.test_running(),
            "test_setup" => self.test_setup(),
            _ => (),
        }
    }
}

すると各テストの実行時の実装を少しシンプルに出来ます。

impl TestCaseTest {
    fn test_running(&mut self) {
        self.test.run();
        assert!(self.test.was_run);
    }
    fn test_setup(&mut self) {
        self.test.run();
        assert!(self.test.was_setup);
    }
}

これで19章の内容は終了です。ここまでのコードをまとめると以下のようになります。

trait TestCase {
    fn new(name: String) -> Self;
    fn run(&mut self);
}

struct WasRun {
    was_run: bool,
    was_setup: bool,
    name: String,
}

impl TestCase for WasRun {
    fn new(name: String) -> WasRun {
        WasRun {
            was_run: false,
            was_setup: false,
            name,
        }
    }

    fn run(&mut self) {
        self.setup();
        match self.name.as_str() {
            "test_method" => self.test_method(),
            _ => (),
        }
    }
}

impl WasRun {
    fn setup(&mut self) {
        self.was_setup = true;
    }

    fn test_method(&mut self) {
        self.was_run = true;
    }
}

struct TestCaseTest {
    name: String,
    test: WasRun,
}

impl TestCase for TestCaseTest {
    fn new(name: String) -> TestCaseTest {
        TestCaseTest {
            name,
            test: WasRun {
                was_run: false,
                was_setup: false,
                name: String::from("test_method"),
            },
        }
    }

    fn run(&mut self) {
        match self.name.as_str() {
            "test_running" => self.test_running(),
            "test_setup" => self.test_setup(),
            _ => (),
        }
    }
}

impl TestCaseTest {
    fn test_running(&mut self) {
        self.test.run();
        assert!(self.test.was_run);
    }
    fn test_setup(&mut self) {
        self.test.run();
        assert!(self.test.was_setup);
    }
}

fn main() {
    TestCaseTest::new(String::from("test_running")).run();
    TestCaseTest::new(String::from("test_setup")).run();
}

終わりに

TDD + Rust で xUnit を実装し始めてみました。やはりテストが先にあると設計方針の決定や自分の実装に安心感が持てる気がします。にここから少し難しくなりそうですが何とか続けていきます。

次回 => テスト駆動開発を実践しながら Rust で xUnit を書いてみる(2)

参考文献

The Rust Programming Language の章末問題を解く(8章)

はじめに

こんにちは、かべです。前回に引き続いて8章の練習問題を解いていきます。

問題はこちらにあります。

環境

以下の環境で動作させています。

  • Ubuntu 20.04.1 LTS
  • rustc 1.61.0 (fe5b13d68 2022-05-18)
  • cargo 1.61.0 (a028ae4 2022-04-29)

平均値、中央値、最頻値の計算

ベクタで保存された整数値に対して、その平均値、中央値、最頻値を求めます。

まずは平均値です。ベクタの値は整数ですが、平均値は整数にならない可能性があるため as を使って f32 に変換しています。今回実装する関数全てに言えることですが、ベクタの所有権が関数に移ってしまわないように参照で渡すことも忘れずに。

fn mean(v: &Vec<i32>) -> f32 {
    let mut m = 0.0;
    for a in v {
        m += *a as f32;
    }

    m/(v.len() as f32)
}

次に中央値です。sort で並べ替えてから中央値になる値を返します。ベクタの中身が変わってしまいますが、今回は他の計算に影響を与えないので配列のコピーは行っていません。今回は簡単のため要素数を奇数にしていますが、要素数が偶数の時も同様に書くことが出来ます。

fn median(v: &mut Vec<i32>) -> i32 {
    v.sort();

    v[v.len()/2]
}

最後に最頻値です。HashMap を使って各数が出てきた回数を数え、出てきた回数が最大となる数の集合をベクタで返しています。max_num で最頻値の登場回数を保持しており、最後に登場回数がそれと等しいもの全てを返り値のベクタに追加しています。

fn mode(v: &Vec<i32>) -> Vec<i32> {
    let mut map = HashMap::new();
    let mut max_num = 0;
    // 各値の登場回数を数える
    for a in v {
        let count = map.entry(*a).or_insert(0);
        *count += 1;
        if max_num < *count {
            max_num = *count;
        }
    }

    // 最頻値となるもの全てをベクタに入れる
    let mut modes: Vec<i32> = Vec::new();
    for (key, value) in &map {
        if *value == max_num {
            modes.push(*key);
        }
    }

    modes
}

以上を実行してみると、次のようになります。

fn main() {
    let mut v = vec![1, 1, 1, 2, 3, 4, 5, 5, 5, 6, 7];

    println!("The mean of v is {}", mean(&v));
    println!("The median of v is {}", median(&mut v));
    println!("The mode of v is {:?}", mode(&v));
}
$ cargo run
The mean of v is 3.6363637
The median of v is 4
The mode of v is [5, 1]

Big Latin への変換

英単語を Big Latin と呼ばれる規則で変換します。この規則に関して問題文から引用すると、

各単語の最初の子音は、 単語の終端に移り、"ay"が足されます。従って、"first"は"irst-fay"になります。ただし、 母音で始まる単語には、お尻に"hay"が付け足されます("apple"は"apple-hay"になります)。 となります。これに従って変換を施すだけです。

今回実装する関数でやりたいことは、

  • 一文字目が a, e, i, o, u でなければ最初の文字が単語の終端にハイフンと共に移り、語尾に ay が付く
  • 一文字目が母音ならば語尾に -hay が付く

これを素直に実装していきます。母音全てが入ったベクタを用意し、一文字目とそれぞれ比べることで変換規則を決めます。

fn biglitin(s: &String) -> String {
    let vowels = vec!["a", "e", "i", "o", "u"];

    for c in vowels {
        if &s[0..1] == c {
            return format!("{}-hay", s);
        }
    }

    return format!("{}-{}ay", &s[1..], &s[0..1]);
}

これを main から呼び出すと次のようになります。

fn main() {
    let s1 = String::from("first");
    let s2 = String::from("apple");
    
    println!("{} -> {}", s1, biglitin(&s1));
    println!("{} -> {}", s2, biglitin(&s2));
}
$ cargo run
first -> irst-fay
apple -> apple-hay

従業員管理のテキストインタフェース

HashMap とベクタで部署と雇用者の関係を管理するためのインターフェースを作ります。今回は

  • 部署への人の追加
  • ある部署にいる人の一覧を見る
  • 各部署にいる人をそれぞれアルファベット順で見る

の3つを実装します。エラー処理などをどこまでやるかが悩ましいところですが、今回はかなり妥協して

  • add {名前} to {部署} で追加(to のチェック省略)
  • list {部署} である部署の一覧取得
  • list all で社員全員の情報を取得
  • end でアプリ終了

という手抜き構成で書きます。

まず入力の取得ですが、7章で学んだ enum を使ってコマンドを良い感じにその中に落とし込んでいきます。今回は Message という名前を使って以下のように定義します。

enum Message {
    Add { department: String, name: String },
    List { department: String },
    End,
    Error,
}

Add が追加、List が一覧取得、End で終了、Error は不正なメッセージをそれぞれ表しています。かなり力技ですが入力をこの enum に分解する関数も作ります。AddList のフィールドを &str にして全体をそれに合わせてあげればもう少しすっきりしそうですが今回は以下の実装でいきます。

fn parse_command(command: Vec<&str>) -> Message {
    match command.get(0) {
        Some(method) => {
            match *method {
                "add" => {
                    match command.get(1) {
                        Some(name) => {
                            match command.get(3) {
                                Some(department) => {
                                    return Message::Add { department: department.to_string(), name: name.to_string() };
                                },
                                None => {
                                    return Message::Error;
                                }
                            }
                        },
                        None => {
                            return Message::Error;
                        }
                    }
                },
                "list" => {
                    match command.get(1) {
                        Some(department) => {
                            return Message::List { department: department.to_string() };
                        },
                        None => {
                            return Message::Error;
                        }
                    }
                },
                "end" => {
                    return Message::End;
                }
                _ => {
                    return Message::Error;
                }
            }
        },
        None => {
            return Message::Error;
        },
    }
}

次に HashMap を操作する関数を実装します。社員の追加、ある部署の社員一覧取得、全ての部署の社員一覧取得を行う関数をそれぞれ add_employee, list_department_employees, list_all_employees で実装します。8.3 節にあるように、「始めてアクセスするキーの場合はデフォルト値(ここでは空のベクタ)を挿入してその値を返し、そうでなければキーに対応する値を返す」という動作を entry(key).or_insert(default) で実現します。後は返された値に対して所望の操作を行うだけです。

fn add_employee(map: &mut HashMap<String, Vec<String>>, department: String, name: String) {
    println!("Added {} to {}.", name, department);
    let names = map.entry(department).or_insert(Vec::new());
    names.push(name);
}

fn list_department_employees(map: &mut HashMap<String, Vec<String>>, department: String) {
    println!("{}:", department);
    let names = map.entry(department).or_insert(Vec::new());
    for name in names {
        println!("{}", name);
    }
}

fn list_all_employees(map: &mut HashMap<String, Vec<String>>) {
    for (department, names) in map {
        println!("{}:", department);
        names.sort();
        for name in names {
            println!("{}", name);
        }
    }
}

最後に、ここまで実装した内容を main 関数から呼び出します。コマンドを enum で定義しているので、match で簡単に挙動を分岐させることが出来ます。コマンドのスペースでの分割は String.split().collect() を使って行います。

fn main() {
    let mut employee: HashMap<String, Vec<String>> = HashMap::new();

    println!("Usage:");
    println!("1. add (name) to (department)");
    println!("2. list all");
    println!("3. list (department)");
    println!("4. end");

    loop {
        println!("Please type your request.");

        let mut command = String::new();
        io::stdin()
            .read_line(&mut command)
            .expect("Failed to read line.");

        let command = command.trim().split(' ').collect();
        let message = parse_command(command);

        match message {
            Message::Add { department, name } => add_employee(&mut employee, department, name),
            Message::List { department } => {
                match department.as_str() {
                    "all" => list_all_employees(&mut employee),
                    _ => list_department_employees(&mut employee, department),
                }
            }
            Message::End => break,
            Message::Error => println!("Invalid request."),
        }
    }
}

簡単な機能を試した様子を以下に示します。

$ cargo run
Usage:
1. add (name) to (department)
2. list all
3. list (department)
4. end
Please type your request.
add Sally to Engineering
Added Sally to Engineering.

Please type your request.
add Amir to Sales
Added Amir to Sales.

Please type your request.
add John to Sales
Added John to Sales.

Please type your request.
list Sales
Sales:
Amir
John

Please type your request.
list all
Engineering:
Sally
Sales:
Amir
John

Please type your request.
end

終わりに

前回に比べて複雑な実装が増えてきました。さらに Rust を学ぶことでより良い実装が出来る気がするので、これからも勉強を続けていきます。

The Rust Programming Language の章末問題を解く(3章)

はじめに

こんにちは、かべです。最近 Rust の勉強を続けているので、そこでの練習を少しずつこちらにもまとめていきます。今は The Rust Programming Language 日本語版 を読み進めているので、章末にまれにある練習問題を解いたものやサンプルのちょっとした改造を載せていきます。

今回のテーマは こちら の練習問題です。

環境

以下の環境で動作させています。

  • Ubuntu 20.04.1 LTS
  • rustc 1.61.0 (fe5b13d68 2022-05-18)
  • cargo 1.61.0 (a028ae4 2022-04-29)

温度の変換

温度を摂氏と華氏で変換するプログラムです。ちなみに摂氏 x 度と華氏 y 度の関係はそれぞれ y = 1.8*x + 32, x = (y-32)/1.8 です。値が欲しいだけならこの関係と元の温度をハードコーディングすれば良いですが、せっかくなので2章で作った数当てゲームで得た知識を使って書いていきます。

最終的な目標は次の通りです。

  • 摂氏から華氏に変換するか、華氏から摂氏に変換するかを選べる
  • 数値を入力すると、選んだ変換方向で計算が行われて結果が得られる
  • 途中で不正な値が入力された時には、再度入力を求める

まずは cargo new --bin temperature などでディレクトリを作成します。始めにどのような変換をするのかを入力から受け取るコードを書きます。これは数当てゲームのコードを少し改造して次のように書くことが出来ます。

loop {
    println!("To change from Celsius to Fahrenheit, press 1.");
    println!("To change from Fahrenheit to Celsius, press 2.");

    // Get input and convert to integer
    let mut command = String::new();
    io::stdin()
        .read_line(&mut command)
        .expect("Failed to read line");

    let command: u32 = match command.trim().parse() {
        Ok(num) => {
            // If input is invalid, let users try again
            if num != 1 && num != 2 {
                continue;
            }
            num
        },
        Err(_) => continue,
    };
}

数当てゲームでは i32 として成立するものが入力された時は全て受け取っていましたが、今回は 1 または 2 が入力された時のみ受け取るように Ok 内を改造しています。

次は温度を入力として受け取り、先ほど入力した command に従って変換を施して出力します。入力の受け取り方は上とほぼ同様で、温度の計算は今回 if 文を使って分岐させることにします。

loop {
    println!("Input the temperature.");

    // Get input and convert to float
    let mut temperature = String::new();
    io::stdin()
        .read_line(&mut temperature)
        .expect("Failed to read line");

    let temperature: f32 = match temperature.trim().parse() {
        Ok(num) => num,
        Err(_) => continue,
    };

    // Calculate converted temperature
    if command == 1 {
        println!("{} C = {} F", temperature, temperature*1.8 + 32.0);
    } else {
        println!("{} F = {} C", temperature, (temperature - 32.0)/1.8);
    }

    break;
}

最後に、温度を受け取るループは command を受け取るループの内側になるのですが、計算した後に抜け出したいのは外側のループなので loop にタグを付けてあげる作業が必要になります。これをすると、最終的なコードは以下のようになります。

use std::io;

fn main() {
    'outer: loop {
        println!("To change from Celsius to Fahrenheit, press 1.");
        println!("To change from Fahrenheit to Celsius, press 2.");

        // Get input and convert to integer
        let mut command = String::new();
        io::stdin()
            .read_line(&mut command)
            .expect("Failed to read line");

        let command: u32 = match command.trim().parse() {
            Ok(num) => {
                // If input is invalid, let users try again
                if num != 1 && num != 2 {
                    continue;
                }
                num
            },
            Err(_) => continue,
        };

        loop {
            println!("Input the temperature.");

            // Get input and convert to float
            let mut temperature = String::new();
            io::stdin()
                .read_line(&mut temperature)
                .expect("Failed to read line");

            let temperature: f32 = match temperature.trim().parse() {
                Ok(num) => num,
                Err(_) => continue,
            };

            // Calculate converted temperature
            if command == 1 {
                println!("{} C = {} F", temperature, temperature*1.8 + 32.0);
            } else {
                println!("{} F = {} C", temperature, (temperature - 32.0)/1.8);
            }

            break 'outer;
        }
    }
}

cargo run で実行してみると次のようになります。

To change from Celsius to Fahrenheit, press 1.
To change from Fahrenheit to Celsius, press 2.
1
Input the temperature.
25
25 C = 77 F

フィボナッチ数列

おなじみフィボナッチ数列の n 項目を計算します。今回は関数を定義して実行してみます。cargo new --bin fibonacciディレクトリを作成します。

まずは定義に従って再帰関数を書いてみます。

fn fibonacci_recursive(n: u64) -> u64 {
    if n == 0 || n == 1 {
        n
    } else {
        fibonacci_recursive(n-1) + fibonacci_recursive(n-2)
    }
}

この関数を n = 10 で実行すると 55 という正しい結果が得られます。ただこの実装は非常に遅く、例えば n = 50 などとするとプログラムがなかなか終了しません。これは再帰の際に同じ値を何度も計算していることに起因します。この問題を解決するには配列や HashMap などで一度計算した値を持っておき、再度その値が必要になった時には即座にそこから取り出すという実装をすることで回避できます。

他の解法として、今回は n の小さいものから計算してみます。

fn fibonacci_dp(n: u64) -> u64 {
    if n == 0 || n == 1 {
        return n;
    }
    let mut a = 0;
    let mut b = 1;
    let mut f = 0;
    for _ in 0..n-1 {
        f = a + b;
        a = b;
        b = f;
    }
    f
}

a, b で前回と前々回の項を持っておき愚直に計算します。すると大きな n に対しては先ほどの実装よりも高速に値を計算できることが分かるかと思います。

次のように main 関数から呼び出すと共に 55 が出力されます。

fn main() {
    let n = 10;
    println!("{}", fibonacci_recursive(n));
    println!("{}", fibonacci_dp(n));
}

ちなみに余談ですが、行列の繰り返し二乗法を用いることでさらに高速な計算が可能になります(「フィボナッチ数列 繰り返し二乗法」などで検索)。

クリスマスソングの出力

最後に The Twelve Days of Christmas という曲の歌詞を、その構造に注目して出力します。歌詞を検索すると分かりますが、反復した構造を持っています(積み上げ歌というらしいです)。for 文を使うとスマートに出力できそうです。歌詞のポイントは以下の通りです。

  • 日が1番ごとに進む first -> second -> ... -> twelfth
  • 同じ内容を繰り返しながら少しずつ歌詞が増えていく
  • 積み上がる歌詞が複数ある時、最後の積み上げに and が付く

この構造に着目して実装していきます。cargo new --bin christmas などでディレクトリを作成します。

まずは日数と積み上がる歌詞を定義します。ここは愚直に書きます。

let numbers = [
    "first",
    "second",
    "third",
    "fourth",
    "fifth",
    "sixth",
    "seventh",
    "eighth",
    "ninth",
    "tenth",
    "eleventh",
    "twelfth",
];

let objects = [
    "a partridge in a pear tree",
    "two turtle doves",
    "three French hens",
    "four calling birds",
    "five golden rings",
    "six geese a-laying",
    "seven swans a-swimming",
    "eight maids a-milking",
    "nine ladies dancing",
    "ten lords a-leaping",
    "eleven pipers piping",
    "twelve drummers drumming",
];

次に歌詞の出力です。この曲の歌詞は「「日数のみ変化する出だし」->「積み上げ部分」を 12 回繰り返す」という構造になっており、12 回の繰り返しと積み上げ部分をそれぞれ for 文で書くことが出来ます。「積み上げ部分で何回繰り返すか」は「今何日目か」に依存しているので、日数のループ部分は配列の中身だけでなく現在のインデックスを取得できる enumerate を使用します。

積み上げ部分は今までの歌詞の前に積み上がっていくのでイテレータを逆から見る .rev() を使うと便利です。また初日以降の 'and' の出力を忘れずに行いましょう。これらをまとめると以下のようなコードになります。

fn main() {
    let numbers = [
        "first",
        "second",
        "third",
        "fourth",
        "fifth",
        "sixth",
        "seventh",
        "eighth",
        "ninth",
        "tenth",
        "eleventh",
        "twelfth",
    ];

    let objects = [
        "a partridge in a pear tree",
        "two turtle doves",
        "three French hens",
        "four calling birds",
        "five golden rings",
        "six geese a-laying",
        "seven swans a-swimming",
        "eight maids a-milking",
        "nine ladies dancing",
        "ten lords a-leaping",
        "eleven pipers piping",
        "twelve drummers drumming",
    ];

    // (i, num) = (0, "first"), (1, "second"), ...
    for (i, num) in numbers.iter().enumerate() {
        println!("On the {} day of Christmas, my true love sent to me", num);
        for j in (0..=i).rev() {
            // Do not forget to print 'and' after day 1 
            if j == 0 && i > 0 {
                print!("and ");
            }
            println!("{}", objects[j]);
        }
        println!();
    }
}

cargo run で実行すると所望の歌詞が得られることが分かります。

終わりに

Rust の勉強がてら簡単な記事を書いてみました。これから更に高度なことにも挑戦していこうと思います。

ゆめみ 2022年春のサーバーサイドエンジニア 1dayインターンシップに参加した話

はじめに

こんにちは、かべです。タイトルの通り 2022/3/18(金) に「2022年春のサーバーサイドエンジニア 1dayインターンシップ」に参加してきました。コードをバリバリ書くタイプのインターンではなくグループで DB 設計や ER 図の作成、API エンドポイントの定義などをすることがメインとなるインターンでしたが、LT やワーク含めて非常にためになるインターンだったため感謝を込めて参加記を残させていただきます。

一日の流れ

一日の流れは以下のようになっています。

  • 11:00 集合、会社説明
  • 12:00 ランチ会
  • 13:00 LT
  • 14:15 グループワーク
  • 16:30 レビュー

朝ゆっくりで時間も長すぎずエンジニアに優しいです。会社説明ではゆめみさんの事業や面白い制度について詳しく知ることが出来ました。僕も技術書と有給無限に欲しいです。

ランチ会の後は LT で今回のワークに必要な知識をインプットします。DB 設計、API エンドポイント作成、認証・認可、AWS の基礎について学ぶことが出来ます。

ワーク

いよいよワーク本番です。3または4人のチームに分かれ、飲食店を舞台にした架空の要件に対して2時間で要件整理とユースケース書き出し、ER図/DB設計、APIエンドポイント作成、認証・認可の設計を2時間で行います(え??)。当然完璧に終わるはずもないのでざっくりやりつつ不整合が生じたら手戻りして直していく形になります。メンターの方から定期的に助言などいただくことが出来るためサーバサイドや設計ほぼ初心者の自分でも問題なくワークに取り組めます。

ワークが終わった後には発表とフィードバックを行います。フィードバックでは自分達の設計で改善できる点以外にも実務で使える知識、推奨される定義やアンチパターンなどについても知ることができて非常に学びがありました。他のグループの考え方や思考の過程も非常に興味深かったです。

まとめ

今回のインターンではサーバサイドや DB に関する知識が少ない状態から要件定義や設計を含む簡単なワークを行えるまでに成長することが出来ました。サーバサイド初心者の方でも楽しめるとても有意義なインターンでした。開催いただきありがとうございました!

notion で会社に関する情報がまとめられているようです!
https://notion.yumemi.co.jp/

Jest 素振り

はじめに

こんにちは、かべです。フロントエンド開発をする時、好き勝手書かずにちょっとずつ業務っぽいこともやってみようということで今回は Jest でテストを書いていきます。スナップショットとかE2Eとかはまた別の機会に。今回は axios を使う処理に対する簡単な単体テストを書きます。

環境

今回の開発は以下の環境で行いました。

  • Ubuntu 20.04.1
  • yarn 1.22.5
  • React 17.0.2
  • TypeScript 4.1.2

React アプリ作成

create-react-app は作成時に Jest を使う環境をすでに整えてくれるので、それに乗っかってやっていきます。npx create-react-app {好きな名前} --template typescript でプロジェクトを作成します。始めから /src 上に App.tsx に対するテストがありますが、今回は使わないので消してしまいます。

プロジェクトが出来たら、必要なものをインストールします。yarn add axiosyarn add -D axios-mock-adapter を実行し、axios とそのモックが使えるようにします。

テスト対象のコード

axios を使って外部にリクエストを送る関数を作成します。今回リクエストの送り先には、cat-facts を使います。リクエストを送るとネコの豆知識が返ってきます。かわいい。

/src 以下に /api というディレクトリを作り、axios を使って次のようなファイルを作ります。

/src/api/api.ts

import axios from "axios";

const baseUrl = "https://cat-fact.herokuapp.com";
export const axiosInstance = axios.create({
  baseURL: baseUrl,
});

export const getCatFact = async (id: string) => {
  try {
    const response = await axiosInstance.get(`/facts/${id}`);

    if (response.status !== 200) {
      throw new Error("server error");
    }

    const fact = response.data;

    return fact;
  } catch (error) {
    throw error;
  }
};

getCatFact という関数は https://cat-fact.herokuapp.com/facts/${id} にリクエストを送ってその中のデータを取得します。

テストに使う期待される返却値を準備しておきます。これは axios のモックが使用する値です。/src/api/ 内に __mocks__ というディレクトリを作り、cat-facts のリクエストに対する返却例を json で保存します。

/src/api/__mocks__/fact.json

{
  "_id": "591f98803b90f7150a19c229",
  "__v": 0,
  "text": "In an average year, cat owners in the United States spend over $2 billion on cat food.",
  "updatedAt": "2018-01-04T01:10:54.673Z",
  "deleted": false,
  "source": "api"
}

テストコード

テストコードを書いていきます。まずは正常系です。describe でテストたちをブロック化し、it(test) で各テストを書くことが出来ます。今回は取得した値の中の text が期待されたものかどうかを確認しています。expect に not を付けると、逆の内容であることをテスト出来ます。

/src/api/api.spec.ts

import MockAdapter from "axios-mock-adapter";

import { axiosInstance, getCatFact } from "./api";
import factData from "./__mocks__/fact.json";

describe("test cat-facts", () => {
  const mock = new MockAdapter(axiosInstance);

  // 使用毎にモック内のデータをクリア
  afterEach(() => {
    mock.reset();
  });

  describe("get cat-facts by id", () => {
    // 正常系
    it("should succeed", async () => {
      const id = "591f98803b90f7150a19c229";
      mock.onGet(`/facts/${id}`).reply(200, factData);

      const catFact = await getCatFact(id);

      expect(catFact.text).toBe(factData.text);
      expect(catFact.text).not.toBe("Dog is the best.");
    });
  });
});

各ブロックでモックをリセットすることを忘れずに行いましょう。これをしないと次のテストでも同じデータが使われてしまいます。

次に異常系です。存在しない ID にリクエストを送るとエラーが発生し、そのステータスコードが 404 であることを確認しています。

/src/api/api.spec.ts

/* 省略 */

describe("test cat-facts", () => {
  /* 省略 */

  describe("get cat-facts by id", () => {
    /* 省略 */

    // 異常系
    it("should return error because it uses wrong id", async () => {
      const id = "591f98803b90f7150a19c221"; // 最後の1文字が違う
      mock
        .onGet(`/facts/${id}`)
        .reply(404, JSON.parse(`{"message":"Fact not found"}`));

      try {
        await getCatFact(id);
      } catch (error) {
        expect(error.response.status).toBe(404);
      }
    });
  });
});

おまけに Jest の機能をいくつか使ってみます。arrayContaining は得られた配列が想定される配列を包含している時に通ります。浮動小数点を扱うときは toBe で比較をすると誤差で通らないため、toBeCloseTo で比較しましょう。第 2 引数は精度を表しています。

/src/api/api.spec.ts

/* 省略 */

describe("test without cat-facts", () => {
  it("contains expected array", () => {
    const expected = ["cat", "dog"];

    expect(["cat", "bird", "dog"]).toEqual(expect.arrayContaining(expected));
  });

  it("has expected property", () => {
    const cat = {
      name: "tama",
      age: 2,
      weight: 2 + 1.1,
    };

    expect(cat.weight).toBeCloseTo(3.1, 5);
  });
});

テストの実行

テストが書けたので実行してみましょう。yarn test を実行すると次のような画面になり、テストが通ります。

f:id:okb_okb:20210511115126p:plain
テスト結果

終わりに

今回は TypeScript の簡単な単体テストを行いました。フロントエンド開発での全体的なテストもいずれ行いたいと思います。

今回のコードはこちらに載せています。

参考

NextAuth.js +TypeScript 素振り

はじめに

こんにちは、かべです。少し前に Firebase を使った認証ページを作ったので、その続編として今度は NextAuth.js を使った認証ページを作っていこうと思います。公式のチュートリアルから飛べる動画でコーディングしながら解説してくれています。英語がかなり聞きやすいのでおすすめです。ただ JavaScript で実装をしていたため、TypeScript に置き換えて作っていきます。

今回は動画内で紹介されているログイン方法の内 GitHub 認証とメールを使った認証について書いていきます。その他のログイン方法についてもこれと大差ありません。

環境

今回の開発は以下の環境で行いました。

  • Ubuntu 20.04.1
  • NextAuth 3.14.8
  • yarn 1.22.5
  • Next 10.1.3
  • React 17.0.2
  • TypeScript 4.0

Next アプリの作成、事前準備

まず、例のごとくアプリを作成していきます。npx create-next-app --example with-typescript ${好きな名前} でさくっと TypeScript の入った Next のアプリを作り、いらない部分を全て消します。完成したらそこへ移動し、yarn add next-auth sqlite3yarn add -D @types/next-auth を実行します。sqlite3 の部分はどんなデータどんなでも良いですが、今回は動画に合わせます。

また、メール認証のために SendGrid のアカウントが必要なので、無ければアカウントを作成します。登録に審査があり1営業日程度かかるらしいので、急ぎの方は注意です。

NextAuth を使用する準備

環境変数の設定

GitHub 連携用の OAuth App を作成します。GitHubSettings / Developer settings / OAuth Apps にアクセスし、New OAuth App からアプリを作成します。作成出来たら Client ID と Client secrets を控えておきましょう。

f:id:okb_okb:20210420225149p:plain
GitHub 連携用の OAuth App

次に、SendGrid の設定を行います。SendGrid のアカウントを作成したら、Email API の中の SMTP Relay を選択します。適当な API Key の名前を決めて Key を作成し、Server、Ports、Username、Password を控えておきましょう。

f:id:okb_okb:20210420225224p:plain
SendGrid の API 作成画面

f:id:okb_okb:20210420225302p:plain
SendGrid の API 作成後

これらの値が取得出来たら、環境変数に設定します。

.env.local

GITHUB_ID='******'
GITHUB_SECRET='******'
EMAIL_SERVER_USER='apikey'
EMAIL_SERVER_PASSWORD='******'
EMAIL_SERVER_HOST='smtp.sendgrid.net'
EMAIL_SERVER_PORT='587'
EMAIL_FROM='***@***'
NEXTAUTH_URL='http://localhost:3000'
DATABASE_URL='sqlite://localhost/:memory:'

/next.config.js

module.exports = {
  env: {
    GITHUB_ID: process.env.GITHUB_ID,
    GITHUB_SECRET: process.env.GITHUB_SECRET,
    AUTH0_CLIENTID: process.env.AUTH0_CLIENTID,
    AUTH0_CLIENT_SECRET: process.env.AUTH0_CLIENT_SECRET,
    AUTH0_DOMAIN: process.env.AUTH0_DOMAIN,
    EMAIL_SERVER_USER: process.env.EMAIL_SERVER_USER,
    EMAIL_SERVER_PASSWORD: process.env.EMAIL_SERVER_PASSWORD,
    EMAIL_SERVER_HOST: process.env.EMAIL_SERVER_HOST,
    EMAIL_SERVER_PORT: process.env.EMAIL_SERVER_PORT,
    EMAIL_FROM: process.env.EMAIL_FROM,
    NEXTAUTH_URL: process.env.NEXTAUTH_URL,
    DATABASE_URL: process.env.DATABASE_URL,
  },
};

[...nextauth].ts の作成

ログインの操作をした時に使用する認証の種類やその設定などを記述するファイルを書きます。これは /pages/api/auth/[...nextauth].ts に作成します。

/pages/api/auth/[...nextauth].ts

import { NextApiRequest, NextApiResponse } from "next";
import NextAuth from "next-auth";
import Providers from "next-auth/providers";

const options = {
  providers: [
    Providers.GitHub({
      clientId: process.env.GITHUB_ID as string,
      clientSecret: process.env.GITHUB_SECRET as string,
    }),
    Providers.Email({
      server: {
        host: process.env.EMAIL_SERVER_HOST as string,
        port: Number(process.env.EMAIL_SERVER_PORT),
        auth: {
          user: process.env.EMAIL_SERVER_USER as string,
          pass: process.env.EMAIL_SERVER_PASSWORD as string,
        },
      },
      from: process.env.EMAIL_FROM,
    }),
  ],
  database: {
    type: "sqlite",
    database: ":memory:",
    synchronize: true,
  },
};

export default (req: NextApiRequest, res: NextApiResponse) =>
  NextAuth(req, res, options);

clientId などの型が stringstring | undefined が認められなかったため楽するために as 使ってますが許してください。今回は GitHub とメールしか使っていませんが、その他の認証を使いたいときは同様に providers の中に好きなものを足せばよいです。

_app.tsx の作成

アプリ全体でセッションを使う準備をします。/pages/_app.tsx にセッションの Provider を用意し、アプリ全体を囲ってあげます。

/pages/_app.tsx

import { AppProps } from "next/app";
import { Provider } from "next-auth/client";

const MyApp = ({ Component, pageProps }: AppProps) => {
  return (
    <Provider session={pageProps.session}>
      <Component {...pageProps} />
    </Provider>
  );
};

export default MyApp;

ここまで出来たら、いよいよアプリの中身を作成していきます。

トップページ

トップページは以下のような機能にします。

  • ログインしていない場合、「ログインしてません」という文とログインボタンを表示
  • ログインしている場合ユーザーの名前かメールを表示し、ログインした人のみ見られるコンテンツへのリンクを載せる。またログアウトボタンも実装。

これを実現するため、NextAuth の signIn, signOut, useSession の3つの機能を使います。useSession<Provider></Provider> 内のセッションを取得できます。

/pages/index.tsx

import { NextPage } from "next";
import Link from "next/link";
import { signIn, signOut, useSession } from "next-auth/client";

const IndexPage: NextPage = () => {
  const [session, loading] = useSession();

  return (
    <>
      {!session && (
        <>
          <div>ログインしていません</div>
          <button onClick={() => signIn()}>ログイン</button>
        </>
      )}
      {session && (
        <>
          こんにちは、{session.user.name ?? session.user.email} さん
          <br />
          <button>
            <Link href="/secret">秘密のページへ</Link>
          </button>
          <button onClick={() => signOut()}>ログアウト</button>
        </>
      )}
    </>
  );
};

export default IndexPage;

Session.user.*** でログイン中のユーザーの情報を取得できます。

ログインした人のみ見られるページ

最後に、ログインした人のみ見られる秘密のページを作ります。まず、ログインしたユーザーのみ叩いて情報を得られる API を作ります。以下はログインしていればステータスコード200 と欲しい情報、ログインしていなければステータスコード 401 を返す API です。TypeScript 用に少し動画と書き方を変えています。

/pages/api/secret/index.ts

import { IncomingMessage, ServerResponse } from "http";
import { getSession } from "next-auth/client";

export default async (req: IncomingMessage, res: ServerResponse) => {
  const session = await getSession({ req });

  if (session) {
    res.statusCode = 200;
    res.setHeader("Content-type", "application/json");
    res.end(
      JSON.stringify({
        content: "秘密のページへようこそ!!",
      })
    );
  } else {
    res.statusCode = 401;
    res.setHeader("Content-type", "application/json");
    res.end(
      JSON.stringify({
        error: "ログインしてください",
      })
    );
  }
};

最後に秘密のページを作ります。これでログインしている時は API からの情報を表示し、ログインしていない時は「ログインしてください」という情報を表示することが出来ます。セッションが変化することで useEffect が走り、情報を取得してきます。セッション管理に使うのは先ほどと同じく useSession です。

import { NextPage } from "next";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/client";

const Secret: NextPage = () => {
  const [session, loading] = useSession();
  const [content, setContent] = useState("");

  useEffect(() => {
    const fetchData = async () => {
      const res = await fetch("/api/secret");
      const json = await res.json();

      if (json.content) {
        setContent(json.content);
      }
    };
    fetchData();
  }, [session]);

  if (typeof window !== "undefined" && loading) return <></>;

  if (!session) {
    return (
      <>
        <h1>ログインしてください</h1>
      </>
    );
  }

  return (
    <>
      <h1>秘密のページ</h1>
      {content}
    </>
  );
};

export default Secret;

これで画像のようなアプリが作れます。

f:id:okb_okb:20210420225441p:plain
トップページ(ログイン前)

f:id:okb_okb:20210420225500p:plain
トップページ(ログイン後)

f:id:okb_okb:20210420225528p:plain
秘密のページ

f:id:okb_okb:20210420225545p:plain
秘密のページ(ログインしていない時)

終わりに

SendGrid や(今回はしてませんが) Auth0 を用いた本格的な認証も簡単に作ることができ、非常に便利でした。Firebase を使った認証も以前やったので、そろそろログインを組み込んで何か作りたい…

今回のコードはこちらに上がっています。

参考