Initial commit: Complete TDD workshop materials

- Workshop documentation (WORKSHOP_PLAN, FACILITATOR_GUIDE, etc.)
- FizzBuzz kata with demo script (git history to be recreated)
- Password Validator kata with demo script and solution
- Shopping Cart kata with demo script and solution
- Setup guide and TDD reference card for participants
This commit is contained in:
fiatcode 2026-03-10 15:32:21 +07:00
commit c3355063f2
26 changed files with 4725 additions and 0 deletions

View file

@ -0,0 +1,372 @@
# Password Validator — Demo Script for Facilitators
**Purpose:** This script helps you demonstrate the Password Validator kata if participants get stuck, or for debriefing discussions. Unlike the FizzBuzz live demo, this is NOT meant to be performed in real-time. Use it as a reference guide.
**Target time:** 45 minutes (participant hands-on)
**Your role:** Circulate, answer questions, refer to this script when helping stuck participants
---
## Overview of the Kata
**Validation rules to implement (in order):**
1. At least 8 characters long
2. At least one uppercase letter (AZ)
3. At least one digit (09)
4. At least one special character (!@#$%^&*)
5. No spaces
6. **Refactor:** All errors reported at once
**Key learning goals:**
- Practice RED-GREEN-REFACTOR rhythm
- Experience code evolution through tests
- Discover the Open-Closed Principle naturally (Step 6)
---
## Suggested Test Order & Implementation
### STEP 1: Minimum Length (Start Simple)
**First test to uncomment (line 23):**
```dart
test('rejects a password shorter than 8 characters', () {
final result = validator.validate('Ab1!xyz');
expect(result.isValid, isFalse);
expect(result.errors, contains('Must be at least 8 characters long'));
});
```
**Expected failure:**
- "The method 'validate' isn't defined for the class 'PasswordValidator'"
**Minimum code to make it GREEN:**
```dart
class ValidationResult {
final bool isValid;
final List<String> errors;
const ValidationResult({required this.isValid, required this.errors});
}
class PasswordValidator {
ValidationResult validate(String password) {
if (password.length < 8) {
return ValidationResult(
isValid: false,
errors: ['Must be at least 8 characters long']
);
}
return ValidationResult(isValid: true, errors: []);
}
}
```
**Next tests (lines 30, 36):**
- "accepts exactly 8 characters" → already passes!
- "accepts longer than 8 characters" → already passes!
**Teaching moment:** "Notice how the general solution covered the edge cases automatically."
---
### STEP 2: Uppercase Letter Required
**Test to uncomment (line 48):**
```dart
test('rejects a password with no uppercase letters', () {
final result = validator.validate('ab1!xyzw');
expect(result.isValid, isFalse);
expect(result.errors, contains('Must contain at least one uppercase letter'));
});
```
**Expected failure:**
- Test fails because 'ab1!xyzw' is 8 chars, so it passes validation
**Minimum code to make it GREEN:**
```dart
ValidationResult validate(String password) {
final errors = <String>[];
if (password.length < 8) {
errors.add('Must be at least 8 characters long');
}
if (!password.contains(RegExp(r'[A-Z]'))) {
errors.add('Must contain at least one uppercase letter');
}
return ValidationResult(
isValid: errors.isEmpty,
errors: errors,
);
}
```
**Teaching moment:** "We switched from single error to a list. This is the design emerging from the tests."
---
### STEP 3: Digit Required
**Test to uncomment (line 67):**
```dart
test('rejects a password with no digits', () {
final result = validator.validate('Ab!!xyzw');
expect(result.isValid, isFalse);
expect(result.errors, contains('Must contain at least one digit'));
});
```
**Code to add:**
```dart
if (!password.contains(RegExp(r'[0-9]'))) {
errors.add('Must contain at least one digit');
}
```
**Teaching moment:** "Notice the pattern? Each rule is just another if-block. We'll refactor this later."
---
### STEP 4: Special Character Required
**Test to uncomment (line 86):**
```dart
test('rejects a password with no special characters', () {
final result = validator.validate('Ab1xxxyw');
expect(result.isValid, isFalse);
expect(result.errors, contains('Must contain at least one special character (!@#\$%^&*)'));
});
```
**Code to add:**
```dart
if (!password.contains(RegExp(r'[!@#$%^&*]'))) {
errors.add(r'Must contain at least one special character (!@#$%^&*)');
}
```
---
### STEP 5: No Spaces Allowed
**Test to uncomment (line 104):**
```dart
test('rejects a password that contains a space', () {
final result = validator.validate('Ab1! xyz');
expect(result.isValid, isFalse);
expect(result.errors, contains('Must not contain spaces'));
});
```
**Code to add:**
```dart
if (password.contains(' ')) {
errors.add('Must not contain spaces');
}
```
**At this point, all tests pass! ✅ But the code has duplication...**
---
### STEP 6: Refactor to Rule Functions (The Insight)
**Test to uncomment (line 124):**
```dart
test('reports all failures for a completely invalid password', () {
final result = validator.validate('bad');
expect(result.isValid, isFalse);
expect(result.errors.length, equals(4)); // length, uppercase, digit, special char
});
```
**This test already passes!** But now we refactor for maintainability.
**Before refactoring, the validate() method looks like:**
```dart
ValidationResult validate(String password) {
final errors = <String>[];
if (password.length < 8) errors.add('Must be at least 8 characters long');
if (!password.contains(RegExp(r'[A-Z]'))) errors.add('Must contain at least one uppercase letter');
if (!password.contains(RegExp(r'[0-9]'))) errors.add('Must contain at least one digit');
if (!password.contains(RegExp(r'[!@#$%^&*]'))) errors.add(r'Must contain at least one special character (!@#$%^&*)');
if (password.contains(' ')) errors.add('Must not contain spaces');
return ValidationResult(isValid: errors.isEmpty, errors: errors);
}
```
**Problem:** Adding a 6th rule means modifying this method. What if we had 20 rules?
**Refactoring insight:** Extract each rule into its own function.
**After refactoring:**
```dart
typedef PasswordRule = String? Function(String password);
class PasswordValidator {
static final List<PasswordRule> _rules = [
_minimumLength,
_requiresUppercase,
_requiresDigit,
_requiresSpecialCharacter,
_noSpaces,
];
ValidationResult validate(String password) {
final errors = _rules
.map((rule) => rule(password))
.whereType<String>()
.toList();
return ValidationResult(
isValid: errors.isEmpty,
errors: errors,
);
}
static String? _minimumLength(String password) {
if (password.length < 8) {
return 'Must be at least 8 characters long';
}
return null;
}
static String? _requiresUppercase(String password) {
if (!password.contains(RegExp(r'[A-Z]'))) {
return 'Must contain at least one uppercase letter';
}
return null;
}
static String? _requiresDigit(String password) {
if (!password.contains(RegExp(r'[0-9]'))) {
return 'Must contain at least one digit';
}
return null;
}
static String? _requiresSpecialCharacter(String password) {
if (!password.contains(RegExp(r'[!@#$%^&*]'))) {
return r'Must contain at least one special character (!@#$%^&*)';
}
return null;
}
static String? _noSpaces(String password) {
if (password.contains(' ')) {
return 'Must not contain spaces';
}
return null;
}
}
```
**Run tests again → Still GREEN! ✅**
**Teaching moment:** "This is the Open-Closed Principle in action:
- **Open for extension:** Add a new rule? Just add it to the `_rules` list.
- **Closed for modification:** The `validate()` method never needs to change.
This refactoring was safe because the tests stayed green throughout!"
---
## Common Participant Questions
### "Why return null instead of false?"
**Answer:** Returning `null` means "no error" while returning a String means "here's the error message." This pattern is common in Dart (nullable types) and makes the code more expressive than true/false.
### "Can I combine all the RegExp patterns into one big pattern?"
**Answer:** You could, but then you lose granular error messages. Try it and see what happens to the tests! (They'll fail because you can't report specific errors.)
### "Why use static methods for rules?"
**Answer:** Rules don't need instance state—they just take a password and return an error. Static methods make this clear. You could also use top-level functions.
### "Should I test the individual rule functions?"
**Answer:** Not necessary in this kata—they're private implementation details. The public `validate()` method is fully tested through its behavior. However, in real production code, you might extract rules to separate files and test them individually.
---
## Debrief Discussion Points (5-10 min after kata)
Ask participants:
1. **"At what point did you notice the duplication?"**
- Most notice it around Step 3-4
- Some push through to Step 5 before refactoring
2. **"What made you decide to refactor?"**
- Listen for: "too much repetition," "hard to add new rules," "messy code"
- Reinforce: Tests gave you confidence to refactor safely
3. **"How did the tests help during refactoring?"**
- They stayed green → proving the behavior didn't change
- Could experiment freely knowing tests would catch mistakes
4. **"What's the benefit of the rule functions approach?"**
- Easier to add new rules (just add to list)
- Each rule is isolated and easier to understand
- Could even load rules from configuration!
5. **"Did anyone try a different approach?"**
- Some might use class-based rules, strategy pattern, etc.
- All valid! TDD doesn't prescribe the design, just guides it
---
## Timing Guidance
**For 45-minute practice session:**
- 0-5 min: Participants read README, set up files
- 5-25 min: Implement Steps 1-5 (about 4 min per rule)
- 25-35 min: Refactor Step 6
- 35-40 min: Bonus edge cases (if time)
- 40-45 min: Quick debrief
**If participants finish early:**
- Direct them to bonus edge cases in the tests
- Challenge: "Can you add a 6th rule without modifying validate()?"
- Suggest: "Try extracting rules to a separate file"
---
## Troubleshooting
### Participant stuck on first test
- Check: Did they create `ValidationResult` class?
- Check: Did they create `validate()` method?
- Hint: "Start with the absolute minimum to make it compile and fail correctly"
### Tests not running
- Check: Did they run `dart pub get`?
- Check: Are they in the `password_validator` directory?
- Check: Did they uncomment the test code?
### Participant trying to write all code at once
- Remind: "Just make *this one test* pass. Nothing more."
- Ask: "What's the simplest code that could work?"
### Participant stuck on refactoring
- Ask: "What pattern do you see repeated?"
- Hint: "What if each rule was a function?"
- Show: The typedef example from the solution
---
## Summary
This kata demonstrates:
- ✅ The RED-GREEN-REFACTOR rhythm in practice
- ✅ How tests enable safe refactoring
- ✅ The Open-Closed Principle emerging naturally from TDD
- ✅ Design evolution driven by tests, not upfront planning
**Key facilitator role:** Encourage participants to feel the pain of duplication *before* refactoring. The refactoring is more meaningful when they discover it themselves.

View file

@ -0,0 +1,43 @@
# Feature A — Password Validator
## Your goal
Implement `PasswordValidator` using strict TDD. **Do not write any production code before you have a failing test.**
## Rules to implement (in order)
| Step | Rule | Error message |
|------|------|---------------|
| 1 | At least 8 characters long | `Must be at least 8 characters long` |
| 2 | At least one uppercase letter (AZ) | `Must contain at least one uppercase letter` |
| 3 | At least one digit (09) | `Must contain at least one digit` |
| 4 | At least one special character (`!@#$%^&*`) | `Must contain at least one special character (!@#$%^&*)` |
| 5 | No spaces | `Must not contain spaces` |
| 6 | All errors reported at once (refactor) | — |
## The rhythm — repeat for every rule
```
1. Uncomment the next test → run → watch it FAIL (Red)
2. Write the minimum code to make it pass → run → GREEN
3. Refactor if needed → run → still GREEN
4. Move to the next test
```
## Running the tests
```bash
dart pub get
dart test
```
## Files
- `lib/password_validator.dart` — your implementation goes here
- `test/password_validator_test.dart` — uncomment tests one at a time
## Hint for Step 6 (the refactor insight)
Once all 5 rules pass, you'll likely have 5 separate `if` blocks in `validate()`.
Think about how to represent each rule as **data** instead of **code**.
What if each rule were just a function in a list?

View file

@ -0,0 +1,25 @@
// TODO: Implement PasswordValidator using TDD.
//
// Rules (implement one at a time, test first!):
// 1. Must be at least 8 characters long
// 2. Must contain at least one uppercase letter
// 3. Must contain at least one digit
// 4. Must contain at least one special character (!@#$%^&*)
// 5. Must not contain spaces
//
// ValidationResult holds a pass/fail flag AND a list of error messages.
// Never return a boolean the caller deserves to know *why* it failed.
class ValidationResult {
final bool isValid;
final List<String> errors;
const ValidationResult({required this.isValid, required this.errors});
}
class PasswordValidator {
ValidationResult validate(String password) {
// TODO: implement
throw UnimplementedError();
}
}

View file

@ -0,0 +1,127 @@
// REFERENCE SOLUTION For facilitator use only.
// Do not share with participants before the session.
//
// This solution shows the evolution of the code through TDD:
// - Steps 1-5: Individual rules implemented as separate if-blocks
// - Step 6: Refactored to use rule functions (Open-Closed Principle)
class ValidationResult {
final bool isValid;
final List<String> errors;
const ValidationResult({required this.isValid, required this.errors});
}
// STEP 6 REFACTOR: This typedef emerged after implementing all 5 rules
// It represents the pattern: "A rule takes a password and returns an error message or null"
// Defining this makes the refactoring much cleaner
typedef PasswordRule = String? Function(String password);
class PasswordValidator {
// STEP 6 REFACTOR: Originally had 5 separate if-blocks in validate()
// Tests revealed the duplication - each rule followed the same pattern
// Extracted each rule into its own function and collected them in a list
// This is the Open-Closed Principle: open for extension (add new rule to list),
// closed for modification (validate() method never changes)
static final List<PasswordRule> _rules = [
_minimumLength, // STEP 1
_requiresUppercase, // STEP 2
_requiresDigit, // STEP 3
_requiresSpecialCharacter, // STEP 4
_noSpaces, // STEP 5
];
ValidationResult validate(String password) {
// STEP 6 REFACTOR: This elegant implementation emerged from refactoring
// It runs all rules, filters out null (passing rules), and collects errors
// Before refactoring, this was 20+ lines of nested if-statements
final errors = _rules
.map((rule) => rule(password))
.whereType<String>() // Filters out nulls (rules that passed)
.toList();
return ValidationResult(
isValid: errors.isEmpty,
errors: errors,
);
}
// STEP 1: From test "rejects password shorter than 8 characters"
// See: test/password_validator_test.dart:23
// This was the first rule implemented - started simple
static String? _minimumLength(String password) {
if (password.length < 8) {
return 'Must be at least 8 characters long';
}
return null;
}
// STEP 2: From test "rejects password with no uppercase letters"
// See: test/password_validator_test.dart:48
// Used RegExp to check for at least one A-Z character
static String? _requiresUppercase(String password) {
if (!password.contains(RegExp(r'[A-Z]'))) {
return 'Must contain at least one uppercase letter';
}
return null;
}
// STEP 3: From test "rejects password with no digits"
// See: test/password_validator_test.dart:67
// Pattern similar to uppercase - started to see duplication here
static String? _requiresDigit(String password) {
if (!password.contains(RegExp(r'[0-9]'))) {
return 'Must contain at least one digit';
}
return null;
}
// STEP 4: From test "rejects password with no special characters"
// See: test/password_validator_test.dart:86
// RegExp gets more specific - only certain special chars allowed
static String? _requiresSpecialCharacter(String password) {
if (!password.contains(RegExp(r'[!@#$%^&*]'))) {
return r'Must contain at least one special character (!@#$%^&*)';
}
return null;
}
// STEP 5: From test "rejects password that contains a space"
// See: test/password_validator_test.dart:104
// Simplest check - just look for space character
static String? _noSpaces(String password) {
if (password.contains(' ')) {
return 'Must not contain spaces';
}
return null;
}
}
// EVOLUTION NOTES:
//
// Initial Implementation (Steps 1-5):
// The validate() method looked like this:
//
// ValidationResult validate(String password) {
// final errors = <String>[];
// if (password.length < 8) errors.add('Must be at least 8 characters long');
// if (!password.contains(RegExp(r'[A-Z]'))) errors.add('Must contain at least one uppercase letter');
// if (!password.contains(RegExp(r'[0-9]'))) errors.add('Must contain at least one digit');
// if (!password.contains(RegExp(r'[!@#$%^&*]'))) errors.add(r'Must contain at least one special character (!@#$%^&*)');
// if (password.contains(' ')) errors.add('Must not contain spaces');
// return ValidationResult(isValid: errors.isEmpty, errors: errors);
// }
//
// This worked! All tests passed. But...
//
// REFACTORING INSIGHT (Step 6):
// - Each rule follows the same pattern: check condition, add error if fails
// - Adding a 6th rule means modifying validate() method (violates Open-Closed)
// - What if we had 20 rules? 50 rules? This becomes unreadable
//
// Solution: Extract each rule into its own function
// - Each function is independent and testable
// - Adding new rule = adding one line to _rules list
// - validate() method never needs to change
//
// This refactoring was driven by the tests - they stayed GREEN throughout!

View file

@ -0,0 +1,8 @@
name: password_validator
description: TDD Workshop - Feature A
environment:
sdk: ^3.0.0
dev_dependencies:
test: ^1.25.0

View file

@ -0,0 +1,157 @@
import 'package:test/test.dart';
import '../lib/password_validator.dart';
// ============================================================
// PASSWORD VALIDATOR Workshop Starter
// Follow Red Green Refactor strictly.
// Uncomment one test at a time. Make it pass. Then the next.
// ============================================================
void main() {
late PasswordValidator validator;
setUp(() {
validator = PasswordValidator();
});
//
// STEP 1 Minimum length
// A password needs at least 8 characters.
// Start here. Everything else builds on a valid-length string.
//
group('Minimum length (8 characters)', () {
test('rejects a password shorter than 8 characters', () {
// TODO: uncomment when ready
// final result = validator.validate('Ab1!xyz');
// expect(result.isValid, isFalse);
// expect(result.errors, contains('Must be at least 8 characters long'));
});
test('accepts a password that is exactly 8 characters', () {
// TODO: uncomment when ready
// final result = validator.validate('Ab1!xyzw');
// expect(result.isValid, isTrue); // assuming other rules pass too
});
test('accepts a password longer than 8 characters', () {
// TODO: uncomment when ready
// final result = validator.validate('Ab1!xyzwqr');
// expect(result.isValid, isTrue);
});
});
//
// STEP 2 Uppercase letter
// At least one AZ character must be present.
//
group('Uppercase letter required', () {
test('rejects a password with no uppercase letters', () {
// TODO: uncomment when ready
// final result = validator.validate('ab1!xyzw');
// expect(result.isValid, isFalse);
// expect(result.errors, contains('Must contain at least one uppercase letter'));
});
test('accepts a password with at least one uppercase letter', () {
// TODO: uncomment when ready
// final result = validator.validate('Ab1!xyzw');
// expect(result.isValid, isTrue);
});
});
//
// STEP 3 Digit required
// At least one 09 character must be present.
//
group('Digit required', () {
test('rejects a password with no digits', () {
// TODO: uncomment when ready
// final result = validator.validate('Ab!!xyzw');
// expect(result.isValid, isFalse);
// expect(result.errors, contains('Must contain at least one digit'));
});
test('accepts a password with at least one digit', () {
// TODO: uncomment when ready
// final result = validator.validate('Ab1!xyzw');
// expect(result.isValid, isTrue);
});
});
//
// STEP 4 Special character required
// At least one of: ! @ # $ % ^ & *
//
group('Special character required', () {
test('rejects a password with no special characters', () {
// TODO: uncomment when ready
// final result = validator.validate('Ab1xxxyw');
// expect(result.isValid, isFalse);
// expect(result.errors, contains('Must contain at least one special character (!@#\$%^&*)'));
});
test('accepts a password with at least one special character', () {
// TODO: uncomment when ready
// final result = validator.validate('Ab1!xyzw');
// expect(result.isValid, isTrue);
});
});
//
// STEP 5 No spaces allowed
//
group('No spaces allowed', () {
test('rejects a password that contains a space', () {
// TODO: uncomment when ready
// final result = validator.validate('Ab1! xyz');
// expect(result.isValid, isFalse);
// expect(result.errors, contains('Must not contain spaces'));
});
test('accepts a password with no spaces', () {
// TODO: uncomment when ready
// final result = validator.validate('Ab1!xyzw');
// expect(result.isValid, isTrue);
});
});
//
// STEP 6 Multiple errors reported at once
// The validator collects ALL failures, not just the first.
// This is the refactor insight: rules should be independent.
//
group('Multiple errors reported together', () {
test('reports all failures for a completely invalid password', () {
// TODO: uncomment when ready
// final result = validator.validate('bad');
// expect(result.isValid, isFalse);
// expect(result.errors.length, equals(4)); // length, uppercase, digit, special char
});
test('a fully valid password has no errors', () {
// TODO: uncomment when ready
// final result = validator.validate('MyP@ssw0rd');
// expect(result.isValid, isTrue);
// expect(result.errors, isEmpty);
});
});
//
// BONUS Edge cases (if you finish early)
//
group('Edge cases', () {
test('empty string is invalid and reports the length error', () {
// TODO: uncomment when ready
// final result = validator.validate('');
// expect(result.isValid, isFalse);
// expect(result.errors, contains('Must be at least 8 characters long'));
});
test('exactly the minimum: 8 chars, all rules satisfied', () {
// TODO: uncomment when ready
// final result = validator.validate('Aa1!aaaa');
// expect(result.isValid, isTrue);
// expect(result.errors, isEmpty);
});
});
}