← 解説「テストで固める — トークン列を丸ごと比べ、境界と壊れた入力を押さえる」に戻る
サンプルコードで身につける: テストで固める — トークン列を丸ごと比べ、境界と壊れた入力を押さえる
解説で学んだ概念を、3つの具体例で確認します。素朴な例から実務寄りの例へと進みます。
1トークン列を 1 行の等値比較でテストする
小さなに を付ける最小の形です。の に PartialEq と Debug を しておくと、期待するトークン列を組んで 1 行で比べられます。 で走らせるとテスト 2 本が通り、ふつうに実行すると main が同じトークン列(StartTag → Char 'h' 'i' → EndTag → Eof)と「期待値と一致: true」を表示します。
Rust
// 字句解析器にテストを付ける最小の形。
// 走らせ方: rustc --test this.rs -o t && ./t (main は無視され、#[test] だけが走る)
// rustc this.rs -o a && ./a (main が走る)
/// トークン。#[derive(PartialEq, Debug)] があるので assert_eq! で列ごと比べられる
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HtmlToken {
StartTag { tag: String },
EndTag { tag: String },
Char(char),
Eof,
}
/// "<p>hi</p>" をトークンの列にする(属性なし・小文字化なしの素朴版)
pub fn tokenize(html: &str) -> Vec<HtmlToken> {
let mut out = Vec::new();
let mut chars = html.chars().peekable();
while let Some(c) = chars.next() {
if c != '<' {
out.push(HtmlToken::Char(c));
continue;
}
let closing = chars.peek() == Some(&'/');
if closing {
chars.next();
}
let mut tag = String::new();
for c in chars.by_ref() {
if c == '>' {
break;
}
tag.push(c);
}
out.push(if closing { HtmlToken::EndTag { tag } } else { HtmlToken::StartTag { tag } });
}
out.push(HtmlToken::Eof);
out
}
// テスト用の小さな部品。期待値を手で組むのが長くなるので関数にする
fn start(tag: &str) -> HtmlToken {
HtmlToken::StartTag { tag: tag.to_string() }
}
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 start_text_end_eof() {
let mut expected = vec![start("p")];
expected.extend(chars("hi"));
expected.push(end("p"));
expected.push(HtmlToken::Eof);
assert_eq!(tokenize("<p>hi</p>"), expected);
}
#[test]
fn empty_input_gives_only_eof() {
assert_eq!(tokenize(""), vec![HtmlToken::Eof]);
}
fn main() {
// main では「テストが見ているもの」をそのまま表示する
let mut expected = vec![start("p")];
expected.extend(chars("hi"));
expected.push(end("p"));
expected.push(HtmlToken::Eof);
let got = tokenize("<p>hi</p>");
for t in &got {
println!("{:?}", t);
}
println!("期待値と一致: {}", got == expected);
println!("空文字列: {:?}", tokenize(""));
}
2境界ケース: 空入力・途中で切れたタグ・孤立した <
字句解析器のテストで価値が高いのは境界ケースです。空入力は [Eof] だけ、"<p" のように > が来ないまま終わる入力は HTML Standard 13.2.5.8 の eof-in-tag として作りかけのタグを捨てて [Eof] だけ、"a < b" の < は次が英字でないので文字、"<DIV></Div>" は小文字に揃う、の 4 つをテストにしました。実行すると 4 つの入力とトークン列が並び、最後に「"a < b" は全部文字か: true」と出ます。
Rust
// 境界ケースをテストにする: 空入力・タグの途中で終わる入力・孤立した "<"・大文字のタグ名。
// HTML Standard 13.2.5.8 (tag name state): EOF が来たら eof-in-tag の parse error として
// EOF トークンだけを出す(作りかけのタグは捨てる)。13.2.5.6 (tag open state): "<" の次が
// 英字でなければ "<" を文字として出す。タグ名は ASCII 小文字に揃える。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HtmlToken {
StartTag { tag: String },
EndTag { tag: String },
Char(char),
Eof,
}
pub fn tokenize(html: &str) -> Vec<HtmlToken> {
let cs: Vec<char> = html.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < cs.len() {
// "<" の次が英字(か "/" + 英字)でなければ、ただの文字
let is_tag = cs[i] == '<'
&& match cs.get(i + 1) {
Some('/') => cs.get(i + 2).map_or(false, |c| c.is_ascii_alphabetic()),
Some(c) => c.is_ascii_alphabetic(),
None => false,
};
if !is_tag {
out.push(HtmlToken::Char(cs[i]));
i += 1;
continue;
}
let closing = cs[i + 1] == '/';
i += if closing { 2 } else { 1 };
let mut tag = String::new();
loop {
match cs.get(i) {
None => {
// eof-in-tag: 作りかけのタグは出さずに終わる
out.push(HtmlToken::Eof);
return out;
}
Some('>') => {
i += 1;
break;
}
Some(c) => tag.push(c.to_ascii_lowercase()),
}
i += 1;
}
out.push(if closing { HtmlToken::EndTag { tag } } else { HtmlToken::StartTag { tag } });
}
out.push(HtmlToken::Eof);
out
}
fn chars_then_eof(s: &str) -> Vec<HtmlToken> {
let mut v: Vec<HtmlToken> = s.chars().map(HtmlToken::Char).collect();
v.push(HtmlToken::Eof);
v
}
#[test]
fn eof_cases() {
assert_eq!(tokenize(""), vec![HtmlToken::Eof]);
assert_eq!(tokenize("<p"), vec![HtmlToken::Eof]); // 作りかけのタグは捨てる
assert_eq!(tokenize("<p>x"), vec![HtmlToken::StartTag { tag: "p".into() }, HtmlToken::Char('x'), HtmlToken::Eof]);
}
#[test]
fn lone_less_than_is_a_character() {
assert_eq!(tokenize("a < b"), chars_then_eof("a < b"));
assert_eq!(tokenize("1<2"), chars_then_eof("1<2"));
}
#[test]
fn tag_names_are_lowercased() {
assert_eq!(
tokenize("<DIV></Div>"),
vec![HtmlToken::StartTag { tag: "div".into() }, HtmlToken::EndTag { tag: "div".into() }, HtmlToken::Eof]
);
}
fn main() {
for input in ["", "<p", "a < b", "<DIV></Div>"] {
println!("{:?} -> {:?}", input, tokenize(input));
}
println!("\"a < b\" は全部文字か: {}", tokenize("a < b") == chars_then_eof("a < b"));
}
3実務に写す: 検索クエリの字句解析を表駆動テストで固める
status:open tag:rust "needs review" のような検索の入力を Filter / Phrase / Word に分ける字句解析器と、入力と期待値の組を表にして一括で assert_eq! する表駆動テストです。閉じ引用符が無い・値が無い a: など、見つけた境界ケースは表に 1 行足すだけで済みます。実行すると 9 ケースすべてに ok が付いて並びます。
Rust
// 実務に写す: 検索ボックスの入力 "status:open tag:rust \"needs review\"" を字句解析し、
// 表駆動(テーブル駆動)のテストで固める。入力と期待値の組を並べ、ループで全部 assert_eq! する。
// 新しい境界ケースが見つかったら表に 1 行足すだけでよい。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QueryToken {
/// key:value の形
Filter { key: String, value: String },
/// 引用符で囲まれた語(引用符は含まない)
Phrase(String),
/// それ以外の 1 語
Word(String),
}
pub fn tokenize(query: &str) -> Vec<QueryToken> {
let mut out = Vec::new();
let mut chars = query.chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
} else if c == '"' {
chars.next();
let mut s = String::new();
for c in chars.by_ref() {
if c == '"' {
break; // 閉じ引用符が無ければ入力の終わりまでが語句(止まらない)
}
s.push(c);
}
out.push(QueryToken::Phrase(s));
} else {
let mut word = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() || c == '"' {
break;
}
word.push(c);
chars.next();
}
out.push(match word.split_once(':') {
Some((k, v)) if !k.is_empty() && !v.is_empty() => {
QueryToken::Filter { key: k.to_string(), value: v.to_string() }
}
_ => QueryToken::Word(word),
});
}
}
out
}
fn cases() -> Vec<(&'static str, Vec<QueryToken>)> {
use self::QueryToken::*;
let f = |k: &str, v: &str| Filter { key: k.to_string(), value: v.to_string() };
vec![
("", vec![]),
(" ", vec![]),
("rust", vec![Word("rust".into())]),
("status:open tag:rust", vec![f("status", "open"), f("tag", "rust")]),
("\"needs review\"", vec![Phrase("needs review".into())]),
("\"unterminated", vec![Phrase("unterminated".into())]), // 閉じ忘れでも止まらない
("a:", vec![Word("a:".into())]), // 値が無いのはフィルタにしない
(":b", vec![Word(":b".into())]),
("x\"y\"", vec![Word("x".into()), Phrase("y".into())]),
]
}
#[test]
fn table_driven() {
for (input, expected) in cases() {
assert_eq!(tokenize(input), expected, "入力: {:?}", input);
}
}
fn main() {
println!("{} ケースを検査", cases().len());
for (input, expected) in cases() {
let got = tokenize(input);
let mark = if got == expected { "ok " } else { "NG " };
println!("{mark}{input:>22?} -> {got:?}");
}
}