Dev Study
← Rust で小さなブラウザを作る コース

13. テストで固める — トークン列を丸ごと比べ、境界と壊れた入力を押さえる

は入力の文字列と出力の列が決まっているので、テストが書きやすい部品です。参照実装のテストは 8 本で、それぞれ 1 つの性質を見ています。空文字なら Eof だけ。開始タグ・文字・終了タグ・Eof の順。タグ名は小文字になる。引用符 3 種類の。値のない属性と自己終了。単独の < は文字。script の中身はタグにならない。 とコメントは消える。

比べ方は (tokens(html), vec![...]) で、トークン列を丸ごと比べます。そのために HtmlToken と Attribute に PartialEq と Debug を導出してあります。失敗したときに Debug の出力で左右の列がそのまま見えるので、どこから食い違ったかがすぐ分かります。ヘルパー(start / end / chars)を小さく作って、期待値を短く書けるようにしてあります。

テストは実装の末尾に mod tests として置き、use super::* で実装を見ています。 なら 、なら 2021 step04_html_tokenizer.rs -o t && ./t で走ります。実装を変えたときにこの 8 本が通れば、少なくともこの 8 つの性質は壊していません。

テストを書く順番の勧めは、「正常な 1 例 → 境界(空・1 文字・最後の) → 壊れた入力」です。字句解析器の壊れた入力は、閉じられないタグ、タグ名の途中の 、引用符が閉じないまま終わる、など。参照実装はどれも落ちずに Eof を返します。

本物のブラウザでは: 字句解析器のテストは が html_tokenizer_test.cc に持ち、さらに両ブラウザが共通で走らせる の html/syntax/parsing( は third_party/blink/web_tests/external/wpt、Firefox は testing/web-platform/tests)に、入力 HTML と期待する木の組が大量に入っています。『入力と期待出力の表』でを縛る形は、この回の と同じです。仕様が細かくを決めているのは、こうして複数の実装が同じ表で検証できるようにするためでもあります。

開発のどこに活かすか: 「入力と出力を丸ごと比べる」テストは、パーサ・フォーマッタ・変換処理・API のレスポンス整形など、純粋な関数ならどこでも使えます。期待値をヘルパーで短く書く工夫と、1 テスト 1 性質の切り方は、業務のテストコードの読みやすさに直結します。

点線の付いた語はタップすると説明が出ます。用語集を開く →

サンプルコード(フレームワーク環境が必要なため表示のみ)

// src/html/tokenizer.rs の末尾のテスト
#[cfg(test)]
mod tests {
    use super::*;

    fn tokens(html: &str) -> Vec<HtmlToken> {
        HtmlTokenizer::new(html).collect()
    }

    fn start(tag: &str, attrs: &[(&str, &str)]) -> HtmlToken {
        HtmlToken::StartTag {
            tag: tag.to_string(),
            self_closing: false,
            attributes: attrs.iter().map(|(n, v)| Attribute::new(n, v)).collect(),
        }
    }

    fn end(tag: &str) -> HtmlToken {
        HtmlToken::EndTag { tag: tag.to_string() }
    }

    fn chars(s: &str) -> Vec<HtmlToken> {
        s.chars().map(HtmlToken::Char).collect()
    }

    #[test]
    fn empty_input_gives_only_eof() {
        assert_eq!(tokens(""), vec![HtmlToken::Eof]);
    }

    #[test]
    fn start_and_end_tag() {
        let mut expected = vec![start("p", &[])];
        expected.extend(chars("hi"));
        expected.push(end("p"));
        expected.push(HtmlToken::Eof);
        assert_eq!(tokens("<p>hi</p>"), expected);
    }

    #[test]
    fn tag_names_are_lowercased() {
        assert_eq!(tokens("<DIV></Div>"), vec![start("div", &[]), end("div"), HtmlToken::Eof]);
    }

    #[test]
    fn attributes_in_three_quote_styles() {
        let t = tokens(r#"<a href="/x" title='t' target=_blank>"#);
        assert_eq!(
            t[0],
            start("a", &[("href", "/x"), ("title", "t"), ("target", "_blank")])
        );
    }

    #[test]
    fn attribute_without_value_and_self_closing() {
        let t = tokens("<input disabled required/>");
        assert_eq!(
            t[0],
            HtmlToken::StartTag {
                tag: "input".into(),
                self_closing: true,
                attributes: vec![Attribute::new("disabled", ""), Attribute::new("required", "")],
            }
        );
    }

    #[test]
    fn lone_less_than_is_a_character() {
        let t = tokens("a < b");
        assert_eq!(t, {
            let mut v = chars("a < b");
            v.push(HtmlToken::Eof);
            v
        });
    }

    #[test]
    fn script_content_is_not_parsed_as_tags() {
        let t = tokens("<script>if (a < b) { x = '</div>'; }</script>");
        let body: String = t[1..t.len() - 2]
            .iter()
            .map(|t| match t {
                HtmlToken::Char(c) => *c,
                other => panic!("script の中にタグが出た: {other:?}"),
            })
            .collect();
        assert_eq!(body, "if (a < b) { x = '</div>'; }");
        assert_eq!(t[t.len() - 2], end("script"));
    }

    #[test]
    fn doctype_and_comment_are_skipped() {
        assert_eq!(
            tokens("<!DOCTYPE html><!-- c --><p>"),
            vec![start("p", &[]), HtmlToken::Eof]
        );
    }
}

手元で動かす(配布物)

サンプルコードで身につける(3本) →

学んだ概念を、素朴な例から実務寄りの例まで3つの具体例で確認します。

公式ドキュメントで詳しく ↗