はじめに

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: コードを改善する

テストがすべて通った状態で、コードを改善します。現在の実装にはいくつかの改善点があります。

問題点の分析

  1. マジックナンバー: 8という数値がハードコードされている
  2. 正規表現の重複: 記号の正規表現が長く複雑
  3. 条件の順序依存: VERY_STRONGSTRONGの判定順が重要
  4. 単一責任の原則違反: 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を適用してみてください。

参考リンク