はじめに#
TDD(テスト駆動開発)を学ぶ際、FizzBuzzのような数値処理の例が多く取り上げられます。しかし、実務で最も頻繁に遭遇するのは文字列のバリデーション処理です。
本記事では、パスワード強度チェック関数を題材に、TDDのRed-Green-Refactorサイクルを実践します。この題材を選んだ理由は以下の3点です。
- 実務で必ず遭遇する: ユーザー登録、ログインなど、あらゆるWebアプリケーションで必要になる
- 複数の条件を組み合わせる: 長さ、文字種、パターンなど、段階的にテストを追加しやすい
- エッジケースが豊富: 境界値、空文字、特殊文字など、テストケース設計の練習に最適
この記事を読み終える頃には、文字列バリデーションのTDD実装パターンを習得し、実務でも自信を持ってテストファーストで開発できるようになっているでしょう。
パスワード強度チェックの要件#
実装するパスワード強度チェック関数は、以下のルールに従って強度を判定します。
| 強度 |
条件 |
| WEAK |
8文字未満 |
| MEDIUM |
8文字以上、かつ英字のみ |
| STRONG |
8文字以上、かつ英字と数字の両方を含む |
| VERY_STRONG |
8文字以上、かつ英字・数字・記号をすべて含む |
また、以下のエッジケースも考慮します。
- 空文字またはnull/undefinedの場合は
INVALIDを返す
- 空白のみの文字列も
INVALIDとする
前提条件#
本記事では、JavaScriptとJavaの両方でパスワード強度チェックのTDD実装を解説します。
| 言語 |
必要な環境 |
| JavaScript |
Node.js 18以上、Jest |
| Java |
JDK 17以上、JUnit 5 |
テストリストの作成#
TDDでは、実装に入る前にテストリストを作成します。このリストが実装のロードマップとなります。
1
2
3
4
5
6
7
8
9
|
パスワード強度チェック テストリスト:
- [ ] nullまたはundefinedの場合はINVALIDを返す
- [ ] 空文字の場合はINVALIDを返す
- [ ] 空白のみの場合はINVALIDを返す
- [ ] 7文字の場合はWEAKを返す(境界値)
- [ ] 8文字で英字のみの場合はMEDIUMを返す(境界値)
- [ ] 8文字以上で英字のみの場合はMEDIUMを返す
- [ ] 8文字以上で英字と数字を含む場合はSTRONGを返す
- [ ] 8文字以上で英字・数字・記号を含む場合はVERY_STRONGを返す
|
最もシンプルなケース(異常系)から始めて、徐々に正常系の複雑なケースへ進みます。
TDD実装の全体フロー#
これから行う実装の流れを視覚化します。
flowchart TB
subgraph phase1["フェーズ1: 異常系の処理"]
R1["Red: null/空文字テスト"] --> G1["Green: INVALID返却"]
G1 --> RF1["Refactor: 検証関数抽出"]
end
subgraph phase2["フェーズ2: WEAK判定"]
R2["Red: 7文字テスト"] --> G2["Green: 長さチェック追加"]
G2 --> RF2["Refactor: 不要"]
end
subgraph phase3["フェーズ3: MEDIUM判定"]
R3["Red: 8文字英字テスト"] --> G3["Green: 文字種チェック追加"]
G3 --> RF3["Refactor: 定数抽出"]
end
subgraph phase4["フェーズ4: STRONG/VERY_STRONG判定"]
R4["Red: 英数字・記号テスト"] --> G4["Green: 複合条件追加"]
G4 --> RF4["Refactor: パターン整理"]
end
phase1 --> phase2
phase2 --> phase3
phase3 --> phase4
style R1 fill:#ffcccc,stroke:#cc0000,color:#000000
style R2 fill:#ffcccc,stroke:#cc0000,color:#000000
style R3 fill:#ffcccc,stroke:#cc0000,color:#000000
style R4 fill:#ffcccc,stroke:#cc0000,color:#000000
style G1 fill:#ccffcc,stroke:#00cc00,color:#000000
style G2 fill:#ccffcc,stroke:#00cc00,color:#000000
style G3 fill:#ccffcc,stroke:#00cc00,color:#000000
style G4 fill:#ccffcc,stroke:#00cc00,color:#000000
style RF1 fill:#cce5ff,stroke:#0066cc,color:#000000
style RF2 fill:#cce5ff,stroke:#0066cc,color:#000000
style RF3 fill:#cce5ff,stroke:#0066cc,color:#000000
style RF4 fill:#cce5ff,stroke:#0066cc,color:#000000それでは、JavaScriptとJavaの両方で実装を進めていきましょう。
サイクル1: 無効な入力の処理#
最初のサイクルでは、異常系から始めます。null、undefined、空文字などの無効な入力を処理します。
Red: 失敗するテストを書く#
JavaScript(Jest)の場合:
1
2
3
4
5
6
7
8
9
10
|
// passwordStrength.test.js
const checkPasswordStrength = require('./passwordStrength');
describe('checkPasswordStrength', () => {
describe('無効な入力', () => {
test('nullの場合はINVALIDを返す', () => {
expect(checkPasswordStrength(null)).toBe('INVALID');
});
});
});
|
Java(JUnit 5)の場合:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// PasswordStrengthCheckerTest.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PasswordStrengthCheckerTest {
@Test
@DisplayName("nullの場合はINVALIDを返す")
void shouldReturnInvalidWhenNull() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("INVALID", checker.check(null));
}
}
|
テストを実行すると、ファイルが存在しないためエラーになります。これがRedフェーズです。
Green: テストを通す最小限のコードを書く#
JavaScript:
1
2
3
4
5
6
|
// passwordStrength.js
function checkPasswordStrength(password) {
return 'INVALID';
}
module.exports = checkPasswordStrength;
|
Java:
1
2
3
4
5
6
|
// PasswordStrengthChecker.java
public class PasswordStrengthChecker {
public String check(String password) {
return "INVALID";
}
}
|
テストが通りました。次に、追加のテストケースを加えます。
追加テスト: 空文字と空白のみ#
JavaScript:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
describe('無効な入力', () => {
test('nullの場合はINVALIDを返す', () => {
expect(checkPasswordStrength(null)).toBe('INVALID');
});
test('undefinedの場合はINVALIDを返す', () => {
expect(checkPasswordStrength(undefined)).toBe('INVALID');
});
test('空文字の場合はINVALIDを返す', () => {
expect(checkPasswordStrength('')).toBe('INVALID');
});
test('空白のみの場合はINVALIDを返す', () => {
expect(checkPasswordStrength(' ')).toBe('INVALID');
});
});
|
Java:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Test
@DisplayName("空文字の場合はINVALIDを返す")
void shouldReturnInvalidWhenEmpty() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("INVALID", checker.check(""));
}
@Test
@DisplayName("空白のみの場合はINVALIDを返す")
void shouldReturnInvalidWhenOnlyWhitespace() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("INVALID", checker.check(" "));
}
|
現在の実装では、すべてINVALIDを返すため、すべてのテストが通ります。これは問題ありません。次のサイクルで実装が一般化されていきます。
サイクル2: 短いパスワード(WEAK)#
8文字未満のパスワードはWEAKと判定します。
Red: 失敗するテストを書く#
JavaScript:
1
2
3
4
5
|
describe('弱いパスワード(WEAK)', () => {
test('7文字の場合はWEAKを返す', () => {
expect(checkPasswordStrength('abcdefg')).toBe('WEAK');
});
});
|
Java:
1
2
3
4
5
6
|
@Test
@DisplayName("7文字の場合はWEAKを返す")
void shouldReturnWeakWhenSevenCharacters() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("WEAK", checker.check("abcdefg"));
}
|
テストを実行すると失敗します。
1
2
|
Expected: "WEAK"
Received: "INVALID"
|
Green: テストを通す最小限のコード#
JavaScript:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
function checkPasswordStrength(password) {
if (password === null || password === undefined) {
return 'INVALID';
}
if (password.trim() === '') {
return 'INVALID';
}
if (password.length < 8) {
return 'WEAK';
}
return 'INVALID';
}
module.exports = checkPasswordStrength;
|
Java:
1
2
3
4
5
6
7
8
9
10
11
|
public class PasswordStrengthChecker {
public String check(String password) {
if (password == null || password.trim().isEmpty()) {
return "INVALID";
}
if (password.length() < 8) {
return "WEAK";
}
return "INVALID";
}
}
|
すべてのテストが通りました。
追加テスト: 境界値の確認#
境界値テストを追加して、実装の正しさを確認します。
JavaScript:
1
2
3
4
5
6
7
|
test('1文字の場合はWEAKを返す', () => {
expect(checkPasswordStrength('a')).toBe('WEAK');
});
test('6文字の場合はWEAKを返す', () => {
expect(checkPasswordStrength('abcdef')).toBe('WEAK');
});
|
Java:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Test
@DisplayName("1文字の場合はWEAKを返す")
void shouldReturnWeakWhenOneCharacter() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("WEAK", checker.check("a"));
}
@Test
@DisplayName("6文字の場合はWEAKを返す")
void shouldReturnWeakWhenSixCharacters() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("WEAK", checker.check("abcdef"));
}
|
サイクル3: 英字のみのパスワード(MEDIUM)#
8文字以上で英字のみのパスワードはMEDIUMと判定します。
Red: 失敗するテストを書く#
JavaScript:
1
2
3
4
5
|
describe('普通のパスワード(MEDIUM)', () => {
test('8文字で英字のみの場合はMEDIUMを返す', () => {
expect(checkPasswordStrength('abcdefgh')).toBe('MEDIUM');
});
});
|
Java:
1
2
3
4
5
6
|
@Test
@DisplayName("8文字で英字のみの場合はMEDIUMを返す")
void shouldReturnMediumWhenEightAlphaCharacters() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("MEDIUM", checker.check("abcdefgh"));
}
|
テストを実行すると失敗します。
1
2
|
Expected: "MEDIUM"
Received: "INVALID"
|
Green: テストを通す最小限のコード#
JavaScript:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
function checkPasswordStrength(password) {
if (password === null || password === undefined) {
return 'INVALID';
}
if (password.trim() === '') {
return 'INVALID';
}
if (password.length < 8) {
return 'WEAK';
}
return 'MEDIUM';
}
module.exports = checkPasswordStrength;
|
Java:
1
2
3
4
5
6
7
8
9
10
11
|
public class PasswordStrengthChecker {
public String check(String password) {
if (password == null || password.trim().isEmpty()) {
return "INVALID";
}
if (password.length() < 8) {
return "WEAK";
}
return "MEDIUM";
}
}
|
ここでもシンプルにMEDIUMを返すだけでテストが通ります。次のサイクルで条件が追加されていきます。
追加テスト: 大文字・小文字混在#
JavaScript:
1
2
3
4
5
6
7
|
test('8文字以上で大文字小文字混在の英字のみの場合はMEDIUMを返す', () => {
expect(checkPasswordStrength('AbCdEfGh')).toBe('MEDIUM');
});
test('10文字で英字のみの場合はMEDIUMを返す', () => {
expect(checkPasswordStrength('abcdefghij')).toBe('MEDIUM');
});
|
Java:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Test
@DisplayName("8文字以上で大文字小文字混在の英字のみの場合はMEDIUMを返す")
void shouldReturnMediumWhenMixedCaseAlpha() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("MEDIUM", checker.check("AbCdEfGh"));
}
@Test
@DisplayName("10文字で英字のみの場合はMEDIUMを返す")
void shouldReturnMediumWhenTenAlphaCharacters() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("MEDIUM", checker.check("abcdefghij"));
}
|
サイクル4: 英字と数字を含むパスワード(STRONG)#
8文字以上で英字と数字の両方を含むパスワードはSTRONGと判定します。
Red: 失敗するテストを書く#
JavaScript:
1
2
3
4
5
|
describe('強いパスワード(STRONG)', () => {
test('8文字以上で英字と数字を含む場合はSTRONGを返す', () => {
expect(checkPasswordStrength('abcdef12')).toBe('STRONG');
});
});
|
Java:
1
2
3
4
5
6
|
@Test
@DisplayName("8文字以上で英字と数字を含む場合はSTRONGを返す")
void shouldReturnStrongWhenAlphaNumeric() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("STRONG", checker.check("abcdef12"));
}
|
テストを実行すると失敗します。
1
2
|
Expected: "STRONG"
Received: "MEDIUM"
|
Green: テストを通す最小限のコード#
JavaScript:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
function checkPasswordStrength(password) {
if (password === null || password === undefined) {
return 'INVALID';
}
if (password.trim() === '') {
return 'INVALID';
}
if (password.length < 8) {
return 'WEAK';
}
const hasLetter = /[a-zA-Z]/.test(password);
const hasNumber = /[0-9]/.test(password);
if (hasLetter && hasNumber) {
return 'STRONG';
}
return 'MEDIUM';
}
module.exports = checkPasswordStrength;
|
Java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
public class PasswordStrengthChecker {
public String check(String password) {
if (password == null || password.trim().isEmpty()) {
return "INVALID";
}
if (password.length() < 8) {
return "WEAK";
}
boolean hasLetter = password.matches(".*[a-zA-Z].*");
boolean hasNumber = password.matches(".*[0-9].*");
if (hasLetter && hasNumber) {
return "STRONG";
}
return "MEDIUM";
}
}
|
追加テスト: さまざまなパターン#
JavaScript:
1
2
3
4
5
6
7
8
9
10
11
|
test('英字が先で数字が後の場合はSTRONGを返す', () => {
expect(checkPasswordStrength('Password123')).toBe('STRONG');
});
test('数字が先で英字が後の場合はSTRONGを返す', () => {
expect(checkPasswordStrength('123Password')).toBe('STRONG');
});
test('英字と数字が交互の場合はSTRONGを返す', () => {
expect(checkPasswordStrength('a1b2c3d4')).toBe('STRONG');
});
|
Java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
@Test
@DisplayName("英字が先で数字が後の場合はSTRONGを返す")
void shouldReturnStrongWhenLetterFirst() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("STRONG", checker.check("Password123"));
}
@Test
@DisplayName("数字が先で英字が後の場合はSTRONGを返す")
void shouldReturnStrongWhenNumberFirst() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("STRONG", checker.check("123Password"));
}
@Test
@DisplayName("英字と数字が交互の場合はSTRONGを返す")
void shouldReturnStrongWhenAlternating() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("STRONG", checker.check("a1b2c3d4"));
}
|
サイクル5: 英字・数字・記号を含むパスワード(VERY_STRONG)#
8文字以上で英字、数字、記号のすべてを含むパスワードはVERY_STRONGと判定します。
Red: 失敗するテストを書く#
JavaScript:
1
2
3
4
5
|
describe('非常に強いパスワード(VERY_STRONG)', () => {
test('8文字以上で英字・数字・記号を含む場合はVERY_STRONGを返す', () => {
expect(checkPasswordStrength('Pass123!')).toBe('VERY_STRONG');
});
});
|
Java:
1
2
3
4
5
6
|
@Test
@DisplayName("8文字以上で英字・数字・記号を含む場合はVERY_STRONGを返す")
void shouldReturnVeryStrongWhenAllTypes() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("VERY_STRONG", checker.check("Pass123!"));
}
|
テストを実行すると失敗します。
1
2
|
Expected: "VERY_STRONG"
Received: "STRONG"
|
Green: テストを通す最小限のコード#
JavaScript:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
function checkPasswordStrength(password) {
if (password === null || password === undefined) {
return 'INVALID';
}
if (password.trim() === '') {
return 'INVALID';
}
if (password.length < 8) {
return 'WEAK';
}
const hasLetter = /[a-zA-Z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSymbol = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password);
if (hasLetter && hasNumber && hasSymbol) {
return 'VERY_STRONG';
}
if (hasLetter && hasNumber) {
return 'STRONG';
}
return 'MEDIUM';
}
module.exports = checkPasswordStrength;
|
Java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
public class PasswordStrengthChecker {
public String check(String password) {
if (password == null || password.trim().isEmpty()) {
return "INVALID";
}
if (password.length() < 8) {
return "WEAK";
}
boolean hasLetter = password.matches(".*[a-zA-Z].*");
boolean hasNumber = password.matches(".*[0-9].*");
boolean hasSymbol = password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?].*");
if (hasLetter && hasNumber && hasSymbol) {
return "VERY_STRONG";
}
if (hasLetter && hasNumber) {
return "STRONG";
}
return "MEDIUM";
}
}
|
追加テスト: さまざまな記号パターン#
JavaScript:
1
2
3
4
5
6
7
8
9
10
11
|
test('記号が@の場合はVERY_STRONGを返す', () => {
expect(checkPasswordStrength('Pass123@')).toBe('VERY_STRONG');
});
test('記号が#の場合はVERY_STRONGを返す', () => {
expect(checkPasswordStrength('Pass123#')).toBe('VERY_STRONG');
});
test('複数の記号を含む場合はVERY_STRONGを返す', () => {
expect(checkPasswordStrength('P@ss123!')).toBe('VERY_STRONG');
});
|
Java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
@Test
@DisplayName("記号が@の場合はVERY_STRONGを返す")
void shouldReturnVeryStrongWithAtSymbol() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("VERY_STRONG", checker.check("Pass123@"));
}
@Test
@DisplayName("記号が#の場合はVERY_STRONGを返す")
void shouldReturnVeryStrongWithHashSymbol() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("VERY_STRONG", checker.check("Pass123#"));
}
@Test
@DisplayName("複数の記号を含む場合はVERY_STRONGを返す")
void shouldReturnVeryStrongWithMultipleSymbols() {
PasswordStrengthChecker checker = new PasswordStrengthChecker();
assertEquals("VERY_STRONG", checker.check("P@ss123!"));
}
|
Refactor: コードを改善する#
テストがすべて通った状態で、コードを改善します。現在の実装にはいくつかの改善点があります。
問題点の分析#
- マジックナンバー:
8という数値がハードコードされている
- 正規表現の重複: 記号の正規表現が長く複雑
- 条件の順序依存:
VERY_STRONGとSTRONGの判定順が重要
- 単一責任の原則違反: 1つの関数に複数の責務がある
リファクタリング後のコード#
JavaScript(リファクタリング後):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
|
// passwordStrength.js
const MIN_LENGTH = 8;
const PATTERNS = {
letter: /[a-zA-Z]/,
number: /[0-9]/,
symbol: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/
};
const STRENGTH = {
INVALID: 'INVALID',
WEAK: 'WEAK',
MEDIUM: 'MEDIUM',
STRONG: 'STRONG',
VERY_STRONG: 'VERY_STRONG'
};
function isValidInput(password) {
return password !== null &&
password !== undefined &&
password.trim() !== '';
}
function hasPattern(password, pattern) {
return pattern.test(password);
}
function countMatchingPatterns(password) {
return Object.values(PATTERNS)
.filter(pattern => hasPattern(password, pattern))
.length;
}
function checkPasswordStrength(password) {
if (!isValidInput(password)) {
return STRENGTH.INVALID;
}
if (password.length < MIN_LENGTH) {
return STRENGTH.WEAK;
}
const matchCount = countMatchingPatterns(password);
if (matchCount === 3) {
return STRENGTH.VERY_STRONG;
}
if (matchCount === 2 && hasPattern(password, PATTERNS.letter) && hasPattern(password, PATTERNS.number)) {
return STRENGTH.STRONG;
}
return STRENGTH.MEDIUM;
}
module.exports = checkPasswordStrength;
|
Java(リファクタリング後):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
// PasswordStrengthChecker.java
import java.util.regex.Pattern;
public class PasswordStrengthChecker {
private static final int MIN_LENGTH = 8;
private static final Pattern LETTER_PATTERN = Pattern.compile("[a-zA-Z]");
private static final Pattern NUMBER_PATTERN = Pattern.compile("[0-9]");
private static final Pattern SYMBOL_PATTERN = Pattern.compile("[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]");
public enum Strength {
INVALID, WEAK, MEDIUM, STRONG, VERY_STRONG
}
public String check(String password) {
if (!isValidInput(password)) {
return Strength.INVALID.name();
}
if (password.length() < MIN_LENGTH) {
return Strength.WEAK.name();
}
boolean hasLetter = containsPattern(password, LETTER_PATTERN);
boolean hasNumber = containsPattern(password, NUMBER_PATTERN);
boolean hasSymbol = containsPattern(password, SYMBOL_PATTERN);
if (hasLetter && hasNumber && hasSymbol) {
return Strength.VERY_STRONG.name();
}
if (hasLetter && hasNumber) {
return Strength.STRONG.name();
}
return Strength.MEDIUM.name();
}
private boolean isValidInput(String password) {
return password != null && !password.trim().isEmpty();
}
private boolean containsPattern(String password, Pattern pattern) {
return pattern.matcher(password).find();
}
}
|
リファクタリング後もすべてのテストが通ることを確認します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
PASS ./passwordStrength.test.js
checkPasswordStrength
無効な入力
✓ nullの場合はINVALIDを返す
✓ undefinedの場合はINVALIDを返す
✓ 空文字の場合はINVALIDを返す
✓ 空白のみの場合はINVALIDを返す
弱いパスワード(WEAK)
✓ 7文字の場合はWEAKを返す
✓ 1文字の場合はWEAKを返す
✓ 6文字の場合はWEAKを返す
普通のパスワード(MEDIUM)
✓ 8文字で英字のみの場合はMEDIUMを返す
✓ 8文字以上で大文字小文字混在の英字のみの場合はMEDIUMを返す
✓ 10文字で英字のみの場合はMEDIUMを返す
強いパスワード(STRONG)
✓ 8文字以上で英字と数字を含む場合はSTRONGを返す
✓ 英字が先で数字が後の場合はSTRONGを返す
✓ 数字が先で英字が後の場合はSTRONGを返す
✓ 英字と数字が交互の場合はSTRONGを返す
非常に強いパスワード(VERY_STRONG)
✓ 8文字以上で英字・数字・記号を含む場合はVERY_STRONGを返す
✓ 記号が@の場合はVERY_STRONGを返す
✓ 記号が#の場合はVERY_STRONGを返す
✓ 複数の記号を含む場合はVERY_STRONGを返す
|
完成版コード#
最終的なテストコードと実装コードを掲載します。
JavaScript版#
テストコード(passwordStrength.test.js):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
|
const checkPasswordStrength = require('./passwordStrength');
describe('checkPasswordStrength', () => {
describe('無効な入力', () => {
test('nullの場合はINVALIDを返す', () => {
expect(checkPasswordStrength(null)).toBe('INVALID');
});
test('undefinedの場合はINVALIDを返す', () => {
expect(checkPasswordStrength(undefined)).toBe('INVALID');
});
test('空文字の場合はINVALIDを返す', () => {
expect(checkPasswordStrength('')).toBe('INVALID');
});
test('空白のみの場合はINVALIDを返す', () => {
expect(checkPasswordStrength(' ')).toBe('INVALID');
});
});
describe('弱いパスワード(WEAK)', () => {
test('1文字の場合はWEAKを返す', () => {
expect(checkPasswordStrength('a')).toBe('WEAK');
});
test('7文字の場合はWEAKを返す', () => {
expect(checkPasswordStrength('abcdefg')).toBe('WEAK');
});
});
describe('普通のパスワード(MEDIUM)', () => {
test('8文字で英字のみの場合はMEDIUMを返す', () => {
expect(checkPasswordStrength('abcdefgh')).toBe('MEDIUM');
});
test('大文字小文字混在の英字のみの場合はMEDIUMを返す', () => {
expect(checkPasswordStrength('AbCdEfGh')).toBe('MEDIUM');
});
test('数字のみの場合はMEDIUMを返す', () => {
expect(checkPasswordStrength('12345678')).toBe('MEDIUM');
});
});
describe('強いパスワード(STRONG)', () => {
test('英字と数字を含む場合はSTRONGを返す', () => {
expect(checkPasswordStrength('abcdef12')).toBe('STRONG');
});
test('大文字小文字と数字を含む場合はSTRONGを返す', () => {
expect(checkPasswordStrength('Password123')).toBe('STRONG');
});
});
describe('非常に強いパスワード(VERY_STRONG)', () => {
test('英字・数字・記号を含む場合はVERY_STRONGを返す', () => {
expect(checkPasswordStrength('Pass123!')).toBe('VERY_STRONG');
});
test('複数の記号を含む場合はVERY_STRONGを返す', () => {
expect(checkPasswordStrength('P@ss123!')).toBe('VERY_STRONG');
});
});
});
|
実装コード(passwordStrength.js):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|
const MIN_LENGTH = 8;
const PATTERNS = {
letter: /[a-zA-Z]/,
number: /[0-9]/,
symbol: /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/
};
const STRENGTH = {
INVALID: 'INVALID',
WEAK: 'WEAK',
MEDIUM: 'MEDIUM',
STRONG: 'STRONG',
VERY_STRONG: 'VERY_STRONG'
};
function isValidInput(password) {
return password !== null &&
password !== undefined &&
password.trim() !== '';
}
function hasPattern(password, pattern) {
return pattern.test(password);
}
function checkPasswordStrength(password) {
if (!isValidInput(password)) {
return STRENGTH.INVALID;
}
if (password.length < MIN_LENGTH) {
return STRENGTH.WEAK;
}
const hasLetter = hasPattern(password, PATTERNS.letter);
const hasNumber = hasPattern(password, PATTERNS.number);
const hasSymbol = hasPattern(password, PATTERNS.symbol);
if (hasLetter && hasNumber && hasSymbol) {
return STRENGTH.VERY_STRONG;
}
if (hasLetter && hasNumber) {
return STRENGTH.STRONG;
}
return STRENGTH.MEDIUM;
}
module.exports = checkPasswordStrength;
|
Java版#
テストコード(PasswordStrengthCheckerTest.java):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
|
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PasswordStrengthCheckerTest {
private PasswordStrengthChecker checker;
@BeforeEach
void setUp() {
checker = new PasswordStrengthChecker();
}
@Nested
@DisplayName("無効な入力")
class InvalidInput {
@Test
@DisplayName("nullの場合はINVALIDを返す")
void shouldReturnInvalidWhenNull() {
assertEquals("INVALID", checker.check(null));
}
@Test
@DisplayName("空文字の場合はINVALIDを返す")
void shouldReturnInvalidWhenEmpty() {
assertEquals("INVALID", checker.check(""));
}
@Test
@DisplayName("空白のみの場合はINVALIDを返す")
void shouldReturnInvalidWhenOnlyWhitespace() {
assertEquals("INVALID", checker.check(" "));
}
}
@Nested
@DisplayName("弱いパスワード(WEAK)")
class WeakPassword {
@Test
@DisplayName("1文字の場合はWEAKを返す")
void shouldReturnWeakWhenOneCharacter() {
assertEquals("WEAK", checker.check("a"));
}
@Test
@DisplayName("7文字の場合はWEAKを返す")
void shouldReturnWeakWhenSevenCharacters() {
assertEquals("WEAK", checker.check("abcdefg"));
}
}
@Nested
@DisplayName("普通のパスワード(MEDIUM)")
class MediumPassword {
@Test
@DisplayName("8文字で英字のみの場合はMEDIUMを返す")
void shouldReturnMediumWhenEightAlpha() {
assertEquals("MEDIUM", checker.check("abcdefgh"));
}
@Test
@DisplayName("数字のみの場合はMEDIUMを返す")
void shouldReturnMediumWhenOnlyNumbers() {
assertEquals("MEDIUM", checker.check("12345678"));
}
}
@Nested
@DisplayName("強いパスワード(STRONG)")
class StrongPassword {
@Test
@DisplayName("英字と数字を含む場合はSTRONGを返す")
void shouldReturnStrongWhenAlphaNumeric() {
assertEquals("STRONG", checker.check("abcdef12"));
}
@Test
@DisplayName("大文字小文字と数字を含む場合はSTRONGを返す")
void shouldReturnStrongWhenMixedCaseAndNumbers() {
assertEquals("STRONG", checker.check("Password123"));
}
}
@Nested
@DisplayName("非常に強いパスワード(VERY_STRONG)")
class VeryStrongPassword {
@Test
@DisplayName("英字・数字・記号を含む場合はVERY_STRONGを返す")
void shouldReturnVeryStrongWhenAllTypes() {
assertEquals("VERY_STRONG", checker.check("Pass123!"));
}
@Test
@DisplayName("複数の記号を含む場合はVERY_STRONGを返す")
void shouldReturnVeryStrongWithMultipleSymbols() {
assertEquals("VERY_STRONG", checker.check("P@ss123!"));
}
}
}
|
実装コード(PasswordStrengthChecker.java):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
import java.util.regex.Pattern;
public class PasswordStrengthChecker {
private static final int MIN_LENGTH = 8;
private static final Pattern LETTER_PATTERN = Pattern.compile("[a-zA-Z]");
private static final Pattern NUMBER_PATTERN = Pattern.compile("[0-9]");
private static final Pattern SYMBOL_PATTERN = Pattern.compile("[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]");
public String check(String password) {
if (!isValidInput(password)) {
return "INVALID";
}
if (password.length() < MIN_LENGTH) {
return "WEAK";
}
boolean hasLetter = containsPattern(password, LETTER_PATTERN);
boolean hasNumber = containsPattern(password, NUMBER_PATTERN);
boolean hasSymbol = containsPattern(password, SYMBOL_PATTERN);
if (hasLetter && hasNumber && hasSymbol) {
return "VERY_STRONG";
}
if (hasLetter && hasNumber) {
return "STRONG";
}
return "MEDIUM";
}
private boolean isValidInput(String password) {
return password != null && !password.trim().isEmpty();
}
private boolean containsPattern(String password, Pattern pattern) {
return pattern.matcher(password).find();
}
}
|
TDD実践から得られる学び#
パスワード強度チェック関数のTDD実装を通じて、以下のポイントを学びました。
異常系から始める#
正常系よりも異常系(null、空文字など)から始めることで、堅牢な実装が自然と生まれます。異常系のテストを後回しにすると、実装が複雑になった後で対応することになり、バグの温床になります。
境界値を意識する#
8文字という境界値の前後(7文字と8文字)をテストすることで、off-by-oneエラーを防げます。境界値テストはTDDにおいて非常に重要です。
段階的に複雑さを増す#
MEDIUM → STRONG → VERY_STRONGと段階的に条件を追加することで、各ステップで確実にテストが通る状態を維持できます。
リファクタリングで設計を改善する#
テストが通った後のリファクタリングで、定数の抽出、関数の分割、命名の改善を行うことで、保守性の高いコードに進化させられます。テストがあるからこそ、安心してリファクタリングできます。
発展課題#
パスワード強度チェックのTDD実装に慣れたら、以下の発展課題に挑戦してみてください。
課題1: パラメタライズドテストへのリファクタリング#
JavaScript(Jest each):
1
2
3
4
5
6
7
8
9
10
|
test.each([
[null, 'INVALID'],
['', 'INVALID'],
['abc', 'WEAK'],
['abcdefgh', 'MEDIUM'],
['abcdef12', 'STRONG'],
['Pass123!', 'VERY_STRONG'],
])('"%s" の強度は %s である', (password, expected) => {
expect(checkPasswordStrength(password)).toBe(expected);
});
|
Java(JUnit ParameterizedTest):
1
2
3
4
5
6
7
8
9
10
11
12
|
@ParameterizedTest
@CsvSource({
", INVALID",
"'', INVALID",
"abc, WEAK",
"abcdefgh, MEDIUM",
"abcdef12, STRONG",
"Pass123!, VERY_STRONG"
})
void shouldReturnCorrectStrength(String password, String expected) {
assertEquals(expected, checker.check(password));
}
|
課題2: 追加ルールの実装#
以下のルールを追加してみましょう。
- 連続した同じ文字(“aaa"など)が含まれる場合は強度を1段階下げる
- 一般的なパスワード(“password”、“123456"など)の場合は
WEAKにする
- ユーザー名と同じパスワードの場合は
INVALIDにする
課題3: 強度をスコアで返す#
文字列ではなく数値スコア(0-100)で強度を返すバージョンを実装してみましょう。
まとめ#
本記事では、パスワード強度チェック関数を題材にTDDのRed-Green-Refactorサイクルを実践しました。
文字列バリデーションは実務で頻繁に遭遇する処理です。本記事で学んだTDDのアプローチを活用することで、以下のメリットが得られます。
- 仕様の明文化: テストがそのまま仕様書になる
- エッジケースの網羅: 異常系や境界値を漏れなくカバーできる
- 安全なリファクタリング: テストに守られながらコードを改善できる
- 回帰バグの防止: 変更による既存機能への影響を即座に検出できる
パスワード強度チェックに限らず、メールアドレスの検証、電話番号のフォーマット、URLのパースなど、さまざまな文字列処理にTDDを適用してみてください。
参考リンク#