visit
We keep practicing this amazing Kata and learning. You can follow the steps!
TL;DR: Javascript is also awesome for TDD
During January 2022 Wordle rush, I wrote an article describing how to create a Wordle with TDD using PHP.
How to Develop a Wordle Game using TDD in 25 Minutes
A few months after, I transcribed the UI version of a Wordle created with Codex Artificial Intelligence.
Step by Step Wordle Creation With Codex AI
As usual, we will focus on the game business logic, knowing we can build the user interface with natural language commands.
In this article, I will use a with . Javascript has many Unit testing frameworks. You can use whatever you like. Let's begin...Following the same principles as the previous article, we will start by defining a Wordle Word.
The smallest information amount in Wordle is a word.We can argue that letter is smaller, but all needed letter protocol is already defined (we might be wrong).
A word is not a char(5).
A word is not an array.
A word is not a string.
This is a common mistake and a bijection violation.
A word and a string have different responsibilities, though they might intersect.
Mixing (accidental) implementation details with (essential) behavior is a widespread mistake.
So we need to define what is a word.
A word in Wordle is a valid 5-letter word.
Let's start with our happy path:test("test01ValidWordLettersAreValid", async function() {
const word = new Word('valid');
expect(['v', 'a', 'l', 'i', 'd']).toStrictEqual(word.letters());
});
We assert that prompting for letters in 'valid' returns an array of the letters.
This is the result:Message: letters from word must be 'valid'
Stack Trace:
ReferenceError: Word is not defined
at Object.<anonymous> (/home/runner/Wordle-TDD/_test_runnertest_suite.js:6:18)
at Promise.then.completed (/home/runner/Wordle-TDD/node_modules/jest-circus/build/utils.js:333:28)
This is fine since we haven't defined what a word is.
We need to create a Word with the letters() function.
class Word {
letters() {
return ['v', 'a', 'l', 'i', 'd'];
}
}
✅ test01ValidWordLettersAreValid
All tests have passed 1/1
test("test02FewWordLettersShouldRaiseException", async function() {
expect(() => {
new Word('vali');
}).toThrow(Error);
});
The test fails as expected...
❌ test02FewWordLettersShouldRaiseException
Stack Trace:
Error: expect(received).toThrow(expected)
Expected constructor: Error
Received function did not throw
at Object.toThrow (/home/runner/Wordle-TDD/_test_runnertest_suite.js:10:23)
✅ test01ValidWordLettersAreValid
1/2 passed, see errors above
We need to change our implementation to make test02 pass (and also test01).
class Word {
constructor(word) {
if (word.length < 5)
throw new Error('Too few letters. Should be 5');
}
letters() {
return ['v', 'a', 'l', 'i', 'd'];
}
}
And the tests pass.
✅ test02FewWordLettersShouldRaiseException
✅ test01ValidWordLettersAreValid
All tests have passed 2/2
test("test03TooManyWordLettersShouldRaiseException", async function() {
expect(() => {
new Word('toolong');
}).toThrow(Error);
});
We run them:
❌ test03TooManyWordLettersShouldRaiseException
Stack Trace:
Error: expect(received).toThrow(expected)
Expected constructor: Error
Received function did not throw
at Object.toThrow (/home/runner/Wordle-TDD/_test_runnertest_suite.js:10:23)
✅ test02FewWordLettersShouldRaiseException
✅ test01ValidWordLettersAreValid
2/3 passed, see errors above
We add the validation:
class Word {
constructor(letters) {
if (letters.length < 5)
throw new Error('Too few letters. Should be 5');
if (letters.length > 5)
throw new Error('Too many letters. Should be 5');
}
letters() {
return ['v', 'a', 'l', 'i', 'd'];
}
}
And all tests passed.
All tests have passed 3/3
We can also add a test for zero words following the Zombie methodology.
Let's do it.test("test04EmptyLettersShouldRaiseException", async function() {
expect(() => {
new Word('');
}).toThrow(Error);
});
✅ test04EmptyLettersShouldRaiseException
✅ test03TooManyWordLettersShouldRaiseException
✅ test02FewWordLettersShouldRaiseException
✅ test01ValidWordLettersAreValid
It is no surprise the test passes since we already have a test covering this scenario.
As this test adds no value, we should remove it.
test("test05InvalidLettersShouldRaiseException", async function() {
expect(() => {
new Word('vali*');
}).toThrow(Error);
});
... and the test is broken since no assertion is raised.
❌ test05InvalidLettersShouldRaiseException
Stack Trace:
Error: expect(received).toThrow(expected)
Expected constructor: Error
Received function did not throw
We need to correct the code...
class Word {
constructor(word) {
if (word.length < 5)
throw new Error('Too few letters. Should be 5');
if (word.length > 5)
throw new Error('Too many letters. Should be 5');
if (word.indexOf('*') > -1)
throw new Error('Word has invalid letters');
}
}
And all tests pass since we are clearly hardcoding.
All tests have passed 5/5
test("test06PointShouldRaiseException", async function() {
expect(() => {
new Word('val.d');
}).toThrow(Error);
});
// Solution
constructor(word) {
if (word.indexOf('*') > -1)
throw new Error('Word has invalid letters');
if (word.indexOf('.') > -1)
throw new Error('Word has invalid letters');
}
class Word {
constructor(word) {
if (word.length < 5)
throw new Error('Too few letters. Should be 5');
if (word.length > 5)
throw new Error('Too many letters. Should be 5');
// Refactor
if (!word.match(/^[a-z]+$/i))
throw new Error('word has invalid letters');
//
}
Lets assert on letters() function.
We left it hard coded. TDD Opens many paths. We need to keep track of all of them until we open new ones.test("test07TwoWordsAreNotTheSame", async function() {
const firstWord = new Word('valid');
const secondWord = new Word('happy');
expect(firstWord).not.toStrictEqual(secondWord);
});
test("test08TwoWordsAreTheSame", async function() {
const firstWord = new Word('valid');
const secondWord = new Word('valid');
expect(firstWord).toStrictEqual(secondWord);
});
And the test fails.
Let's use the parameter we are sending to them.
class Word {
constructor(word) {
// ...
this._word = word;
}
letters() {
return ['v', 'a', 'l', 'i', 'd'];
}
}
✅ test08TwoWordsAreTheSame
✅ test07TwoWordsAreNotTheSame
✅ test06PointShouldRaiseException
✅ test05InvalidLettersShouldRaiseException
✅ test04EmptyLettersShouldRaiseException
✅ test03TooManyWordLettersShouldRaiseException
✅ test02FewWordLettersShouldRaiseException
✅ test01ValidWordLettersAreValid
All tests have passed 8/8
Remember letters() function was hardcoded until now.
test("test09LettersForGrassWord", async function() {
const grassWord = new Word('grass');
expect(['g','r','a','s','s']).toStrictEqual(grassWord.letters());
});
And the test fails as expected.
❌ test09LettersForGrassWord
Stack Trace:
Error: expect(received).toStrictEqual(expected) // deep equality
- Expected - 4
+ Received + 4
Array [
- "v",
+ "g",
+ "r",
"a",
- "l",
- "i",
- "d",
+ "s",
+ "s",
]
at Object.toStrictEqual (/home/runner/Wordle-TDD/_test_runnertest_suite.js:9:37)
Let's change the letters() function since we've been faking it.
class Word {
letters() {
return this._word.split("");
}
}
test("test10ComparisonIsCaseInsensitve", async function() {
const firstWord = new Word('valid');
const secondWord = new Word('VALID');
expect(firstWord).toStrictEqual(secondWord);
});
Test fails.
We need to take a decision.
We decide all our domains will be lowercase.
We will not allow uppercase letters despite the UI having caps.
We won't do magic conversions.
We change the test to catch invalid uppercase letters and fix them.
test("test10NoUppercaseAreAllowed", async function() {
expect(() => {
new Word('vAliD');
}).toThrow(Error);
});
class Word {
constructor(word) {
// We remove the /i modifier on the regular expression
if (!word.match(/^[a-z]+$/))
throw new Error('word has invalid letters');
}
test("test11XXXXIsnotAValidWord", async function() {
expect(() => {
new Word('XXXXX');
}).toThrow(Error);
});
This test fails.
We are not catching invalid English 5-letter words.
test("test11EmptyGameHasNoWinner", async function() {
const game = new Game()
expect(false).toStrictEqual(game.hasWon());
});
Test fails.
We need to create the class and the function.
class Game {
hasWon() {
return false;
}
}
test("test12EmptyGameWordsAttempted", async function() {
const game = new Game()
expect([]).toStrictEqual(game.wordsAttempted());
});
class Game {
wordsAttempted() {
return [];
}
}
✅ test12EmptyGameWordsAttempted
...
All tests have passed 12/12
test("test13TryOneWordAndRecordIt", async function() {
var game = new Game();
game.addAttempt(new Word('loser'));
expect([new Word('loser')]).toStrictEqual(game.wordsAttempted());
});
class Game {
constructor() {
this._attempts = [];
}
hasWon() {
return false;
}
wordsAttempted() {
return this._attempts;
}
addAttempt(word) {
this._attempts.push(word);
}
}
test("test14TryOneWordAndDontLooseYet", async function() {
const game = new Game();
game.addAttempt(new Word('loser'));
expect(false).toStrictEqual(game.hasLost());
});
class Game {
hasLost() {
return false;
}
}
test("test15TryFiveWordsLoses", async function() {
const game = new Game([new Word('loser'), new Word('music')], new Word('music'));
game.addAttempt(new Word('loser'));
game.addAttempt(new Word('loser'));
game.addAttempt(new Word('loser'));
game.addAttempt(new Word('loser'));
game.addAttempt(new Word('loser'));
expect(false).toStrictEqual(game.hasLost());
// last attempt
game.addAttempt(new Word('loser'));
expect(true).toStrictEqual(game.hasLost());
});
class Game {
hasLost() {
return this._attempts.length > 5;
}
}
test("test16TryToPlayInvalid", async function() {
const game = new Game([]);
expect(() => {
game.addAttempt(new Word('xxxxx'));
}).toThrow(Error);
});
The test fails as expected.
We fix it.
class Game {
constructor(validWords) {
this._attempts = [];
this._validWords = validWords;
}
addAttempt(word) {
if (!this._validWords.some(validWord => validWord.sameAs(word))) {
throw new Error(word.letters() + " is not a valid word");
}
this._attempts.push(word);
}
}
// fix previous tests
// change
const game = new Game([]);
// to
const game = new Game([new Word('loser')]);
Also add:
Class Word {
sameAs(word) {
return word.word() == this.word();
}
}
and the test is fixed, but...
test16TryToPlayInvalid
❌ test15TryFiveWordsLoses
Stack Trace:
TypeError: Cannot read properties of undefined (reading 'includes')
❌ test14TryOneWordAndDontLooseYet
Stack Trace:
TypeError: Cannot read properties of undefined (reading 'includes')
❌ test13TryOneWordAndRecordIt
Stack Trace:
TypeError: Cannot read properties of undefined (reading 'includes')
✅ test12EmptyGameWordsAttempted
✅ test10EmptyGameHasNoWinner
12/15 passed, see errors above
test("test17GuessesWord", async function() {
const words = [new Word('happy')];
const correctWord = new Word('happy');
const game = new Game(words, correctWord);
expect(game.hasWon()).toStrictEqual(false);
game.addAttempt(new Word('happy'));
expect(game.hasWon()).toStrictEqual(true);
});
// we need to store the correct word
class Game {
constructor(validWords, correctWord) {
this._attempts = [];
this._validWords = validWords;
this._correctWord = correctWord;
}
hasWon() {
return this._attempts.some(attempt => attempt.sameAs(this._correctWord));
}
We added the Correct Word.
We need to assert this word is in the dictionary.test("test18CorrectWordNotInDictionary", async function() {
const words = [new Word('happy')];
const correctWord = new Word('heros');
expect(() => {
new Game(words, correctWord);
}).toThrow(Error);
});
class Game {
constructor(validWords, correctWord) {
if (!validWords.some(validWord => validWord.sameAs(correctWord)))
throw new Error("Correct word " + word.word() + " is not a valid word");
}
✅ test18CorrectWordNotInDictionary
...
✅ test01ValidWordLettersAreValid
All tests have passed 17/17
test("test19TryFiveWordsWins", async function() {
const game = new Game([new Word('loser'),new Word('heros')],new Word('heros'));
game.addAttempt(new Word('loser'));
game.addAttempt(new Word('loser'));
game.addAttempt(new Word('loser'));
game.addAttempt(new Word('loser'));
game.addAttempt(new Word('loser'));
expect(false).toStrictEqual(game.hasLost());
expect(false).toStrictEqual(game.hasWon());
// last attempt
game.addAttempt(new Word('heros'));
expect(false).toStrictEqual(game.hasLost());
expect(true).toStrictEqual(game.hasWon());
});
// And the correction
hasLost() {
return !this.hasWon() && this._attempts.length > 5;
}
test("test20LettersDoNotMatch", async function() {
const firstWord = new Word('trees');
const secondWord = new Word('valid');
expect([]).toStrictEqual(firstWord.matchesPositionWith(secondWord));
});
As usual, we get an undefined function error:
❌ test20LettersDoNotMatch
Stack Trace:
TypeError: firstWord.matchesPositionWith is not a function
Let's fake it as usual.
class Word {
matchesPositionWith(correctWord) {
return [];
}
}
test("test21MatchesFirstLetter", async function() {
const guessWord = new Word('trees');
const correctWord = new Word('table');
expect([1]).toStrictEqual(guessWord.matchesPositionWith(correctWord));
});
Fails.
We need to define it better
This is a good enough algorithm.
Ugly and imperative
We will refactor it later, for sure.
matchesPositionWith(correctWord) {
var positions = [];
for (var currentPosition = 0;
currentPosition < this.letters().length;
currentPosition++) {
if (this.letters()[currentPosition] == correctWord.letters()[currentPosition]) {
positions.push(currentPosition + 1);
//Humans start counting on 1
}
}
return positions;
}
And all the tests pass.
test("test23MatchesIncorrectPositions", async function() {
const guessWord = new Word('trees');
const correctWord = new Word('drama');
expect([2]).toStrictEqual(guessWord.matchesPositionWith(correctWord));
expect([]).toStrictEqual(guessWord.matchesIncorrectPositionWith(correctWord));
});
// The simplest solution
class Word {
matchesIncorrectPositionWith(correctWord) {
return [];
}
}
test("test24MatchesIncorrectPositionsWithMatch", async function() {
const guessWord = new Word('alarm');
const correctWord = new Word('drama');
expect([3]).toStrictEqual(guessWord.matchesPositionWith(correctWord));
expect([1, 4, 5]).toStrictEqual(guessWord.matchesIncorrectPositionWith(correctWord));
// A*ARM vs *RAMA
expect([3]).toStrictEqual(correctWord.matchesPositionWith(guessWord));
expect([2, 4, 5]).toStrictEqual(correctWord.matchesIncorrectPositionWith(guessWord));
});
Let's go for the implementation
class Word {
matchesIncorrectPositionWith(correctWord) {
var positions = [];
for (var currentPosition = 0; currentPosition < 5; currentPosition++) {
if (correctWord.letters().includes(this.letters()[currentPosition])) {
positions.push(currentPosition + 1);
}
}
return positions.filter(function(position) {
return !this.matchesPositionWith(correctWord).includes(position);
}.bind(this));
}
}
}
That's it.
We have implemented a very small model with all meaningful rules.
All tests have passed 21/21
test("test20220911", async function() {
const correctWord = new Word('tibia');
// Sorry for the spoiler
const words = [
// all the words I've tried
new Word('paper'),
new Word('tools'),
new Word('music'),
new Word('think'),
new Word('twins'),
new Word('tight'),
// plus the winning word
correctWord
];
const game = new Game(words, correctWord);
expect(game.hasWon()).toStrictEqual(false);
expect(game.hasLost()).toStrictEqual(false);
// P(A)PER vs TIBIA
game.addAttempt(new Word('paper'));
expect([]).toStrictEqual((new Word('paper')).matchesPositionWith(correctWord));
expect([2]).toStrictEqual((new Word('paper')).matchesIncorrectPositionWith(correctWord));
// [T]OOLS vs TIBIA
expect([1]).toStrictEqual((new Word('tools')).matchesPositionWith(correctWord));
expect([]).toStrictEqual((new Word('tools')).matchesIncorrectPositionWith(correctWord));
game.addAttempt(new Word('tools'));
// MUS[I]C vs TIBIA
expect([4]).toStrictEqual((new Word('music')).matchesPositionWith(correctWord));
expect([]).toStrictEqual((new Word('music')).matchesIncorrectPositionWith(correctWord));
game.addAttempt(new Word('music'));
// [T]H(I)NK vs TIBIA
expect([1]).toStrictEqual((new Word('think')).matchesPositionWith(correctWord));
expect([3]).toStrictEqual((new Word('think')).matchesIncorrectPositionWith(correctWord));
game.addAttempt(new Word('think'));
// [T]W(I)NS vs TIBIA
expect([1]).toStrictEqual((new Word('twins')).matchesPositionWith(correctWord));
expect([3]).toStrictEqual((new Word('twins')).matchesIncorrectPositionWith(correctWord));
game.addAttempt(new Word('twins'));
expect(game.hasWon()).toStrictEqual(false);
expect(game.hasLost()).toStrictEqual(false);
// [T][I]GHT vs TIBIA
expect([1, 2]).toStrictEqual((new Word('tight')).matchesPositionWith(correctWord));
expect([]).toStrictEqual((new Word('tight')).matchesIncorrectPositionWith(correctWord));
game.addAttempt(new Word('tight'));
expect(game.hasWon()).toStrictEqual(false);
expect(game.hasLost()).toStrictEqual(true);
});
(You will find more daily examples in the repo)
test("test25VeryComplexWrongPositions", async function() {
const guessWord = new Word('geese');
const correctWord = new Word('those');
expect([4, 5]).toStrictEqual(guessWord.matchesPositionWith(correctWord));
expect(['s','e']).toStrictEqual(guessWord.lettersAtCorrectPosition(correctWord));
expect([]).toStrictEqual(guessWord.lettersAtWrongtPosition(correctWord));
expect([]).toStrictEqual(guessWord.matchesIncorrectPositionWith(correctWord));
// GEE[S][E] vs THOSE
const anotherGuessWord = new Word('added');
const anotherCorrectWord = new Word('dread');
expect([5]).toStrictEqual(anotherGuessWord.matchesPositionWith(anotherCorrectWord));
expect(['d']).toStrictEqual(anotherGuessWord.lettersAtCorrectPosition(anotherCorrectWord));
expect(['a', 'd', 'e']).toStrictEqual(anotherGuessWord.lettersAtWrongtPosition(anotherCorrectWord));
expect([1, 2, 4]).toStrictEqual(anotherGuessWord.matchesIncorrectPositionWith(anotherCorrectWord));
// (A)(D)D(E)[D] vs DREAD
const yetAnotherGuessWord = new Word('mamma');
const yetAnotherCorrectWord = new Word('maxim');
expect([1, 2]).toStrictEqual(yetAnotherGuessWord.matchesPositionWith(yetAnotherCorrectWord));
expect(['m', 'a']).toStrictEqual(yetAnotherGuessWord.lettersInCorrectPosition(yetAnotherCorrectWord));
expect(['m']).toStrictEqual(yetAnotherGuessWord.lettersAtWrongtPosition(yetAnotherCorrectWord));
expect([3]).toStrictEqual(yetAnotherGuessWord.matchesIncorrectPositionWith(yetAnotherCorrectWord));
// [M][A](M)MA vs MAXIM
});
Let's steal the algorithm from the article.
matchesIncorrectPositionWith(correctWord) {
const correctPositions = this.matchesPositionWith(correctWord);
var incorrectPositions = [];
var correctWordLetters = correctWord.letters();
var ownWordLetters = this.letters();
for (var currentPosition = 0; currentPosition < 5; currentPosition++) {
if (correctPositions.includes(currentPosition + 1)) {
// We can use these wildcards since they are no valid letters
correctWordLetters.splice(currentPosition, 1, '*');
ownWordLetters.splice(currentPosition, 1, '+');
}
}
for (var currentPosition = 0; currentPosition < 5; currentPosition++) {
const positionInCorrectWord = correctWordLetters.indexOf(ownWordLetters[currentPosition]);
if (positionInCorrectWord != -1) {
correctWordLetters.splice(positionInCorrectWord, 1, '*');
ownWordLetters.splice(currentPosition, 1, '+');
incorrectPositions.push(currentPosition + 1);
}
}
return incorrectPositions;
}
We need to add another function (which will be useful for keyboard colors).
lettersAtCorrectPosition(correctWord) {
return this.matchesPositionWith(correctWord).map(position => this.letters()[position -1 ]);
}
lettersAtWrongtPosition(correctWord) {
return this.matchesIncorrectPositionWith(correctWord).map(position => this.letters()[position -1]);
}
DREAD vs ADDED
DREA* vs ADDE+
DRE** vs +DDE+
*RE** vs ++DE+
*R*** vs ++D++
This solution is different and more complete than the previous one.
The wordle rules have not changed. According to David Farley, we need to be experts at learning. And we learn by practicing katas like this one. We end up with 2 compact classes where we defined our business model.This small model has a real 1:1 bijection in the MAPPER to the real world.
It is ready to evolve. This game is a metaphor for real software engineering. Hope you find it interesting and follow the kata with me.