Let's build a version of the game Bulls and Cows together. Bulls and cows is an old game, usually played with two players with pen and paper.
Our version of the game will be played in the terminal. We will be using vanilla JavaScript and Node.js to build it. We will use Jest to test our application as we develop the game functionality.
Rules of the game
The original game is for two players. Each player comes up with a number that consists of four unique digits.
For example:
1112
would not be an acceptable secret number.1234
on the other hand is an acceptable value for the secret number, since each digit is unique.
Then, players take turns to guess the number the other one has. After each guess, the player will get a hint to help them make a better guess next time around. The hint tells how many bulls and how many cows there were. What are bulls and cows? If there are any matching digits and they are in their right positions, they are counted as "bulls". If the digits are correct but in the wrong positions, they are counted as "cows".
For example, with a secret number 4271
:
Player's try: 1234
Hint: 1 bull and 2 cows
The bull is the number 2
as it is in the right position.
The cows are numbers 4
and 1
, as they exist in the secret number but they are not in their correct positions.
It is important to not disclose which digit is a cow and which one is bull, just how many there are (if any).
There is no limit on the number of guesses and guessing continues until the player figures out the other player's secret number. Once the player has guessed the secret number correctly, the game ends.
To simplify the game a bit in our implementation we will have only one player playing against the computer.
This is the core functionality we want to build and there will be plenty of cool features we can add later on to make it more interesting.
Building the game step by step
Game Flow
Having the rules of the game in mind, we can draw a graph of the game flow to visualize the overall logic.
The game is in a loop - it keeps asking the player to take a guess. The only way to break out of the loop is when the guess is correct. If the guess is not correct, the player is provided with feedback how many bulls and how many cows there are if any, so they can improve their guess.
Functionality
We now have the game flow visualized, we should think of the tasks we need to implement in order to make the game work.
Let's take a look at the rules of the game and based on them write down all the tasks that we can think of. It does not have to be exhaustive, but should be extensive. You can give it a try on your own and try to come up with tasks.
Here is my list of possible tasks:
- We need to generate a 4 digit number where each digit is unique.
- We need to give feedback to the user how many bulls and how many cows they hit.
- We need to to get user input for the guesses.
- We need to validate user input.
- We need to know when the game is over.
Project setup
We will run the game directly in the terminal with Node and there will be no UI.
You need to have Node.js installed on your machine. To verify the installation you can type node -v
.
Next, let's setup the project folder and add the necessary files:
mkdir bulls-and-cows
cd bulls-and-cows
touch index.js # create the main file
npm init -y # create a package.json file and answer yes to all questions
After running the above commands you will have two files: one JavaScript file, and a package.json
.
We need the package.json
file for some packages we need to install and to list the scripts we want to run.
To run the game we will use:
node index.js
But you will see, we will not run the game for a while. Instead we will build piece by piece each of the tasks listed above, write tests, and then at the very end assemble them together.
Setup tests
Before starting to write any code, we need to setup our testing configuration.
We will use a testing framework called Jest. Let's install it as dev dependency:
npm i --save-dev jest
Next, we will add a simple config file:
npx jest --init
Jest will ask few questions, here are the answers I used:
We then need to update our package.json
with a script to run tests. There is a dummy test script, let's update it to use jest:
"scripts": {
"test": "jest"
},
We can now run our tests with node run test
.
Writing the game functionality
Generate secret number with four unique digits
All right, with testing configuration setup, let's get started with generating a unique number for the user to guess.
Sidenote:
We don't have to start from this bit, it is rather arbitrary. The function is easily isolatable and has clear requirements so it makes for a good start.
From structuring work point of view, it is not blocking us in any way. We could easily hardcode a number that matches the requirements and continue with other tasks or the core of the game. Harcoding the number means every time we play the game, it will be the same number. It can be useful to keep working on other parts, or when splitting work between team members. While one is working on developing the unique number function, another one can assume it is already working and keep developing the rest of the program.
We will organize the functionality in multiple files where each file will contain only one function. At times we might end up adding couple of closely related functions in one file, but as a rule of thumb we try to keep one helper function per file. Whenever we create a file for a new piece of functionality, we also create a test file.
Let's create a file for this function get-secret-number.js
and create the function signature with a module export:
// get-secret-number.js
const getSecretNumber = () => {};
module.exports = getSecretNumber;
Before we write any of the body of the function, let's create a test file for this function get-secret-number.test.js
. We use the same name as the function file, but we add .test
before the file extension:
Looking at the rules of the game, we already know there are certain requirements for the secret number:
- we want to make sure there are 4 digits
- we want to make sure the 4 digits are unique
- we want to make sure the secret number is a valid number
Each of thee requirements will become a test expectation to be verified by our tests. For each of these expectations, we will write a test, before writing any functionality. All of our tests will be failing initially, and as we write our functionality they will turn green.
// get-secret-number.test.js
it("generates a 4 digit number", () => {
const num = getSecretNumber();
expect(num).toBeLessThan(9999);
expect(num).toBeGreaterThan(1000);
});
We expect that that secret number consists of 4 digits and we verify this in our first test.
Let's add the other two tests:
// get-secret-number.test.js
it("returns a valid number", () => {
const num = getSecretNumber();
expect(Number.isNaN(num)).toBe(false);
});
it("generates a number where all 4 digits are unique", () => {
const num = getSecretNumber().toString().split("");
const uniqueValuesSet = new Set(num);
expect(Array.from(uniqueValuesSet).length).toBe(4);
});
There are couple of ways to write the function to generate the secret number.
One way would be to generate a random number between 1000 and 9999 and keep checking if all digits are different.
While this approach would work, it might take a while for the function to return a result as the odds of getting a 4 digit number with all unique digits is rather small.
The time required to get the right number will vary, but on average it will be a consistently high number.
I have left the implementation of the function in the discarded-examples
folder. I have used the Node.js performance hooks to measure the time needed to return a result - feel free to give it a try.
Another approach we can take is to generate each digit individually by picking a random number between 0 and 9, and then put them together to create a 4 digit number. This way we can get a result much faster. Here is what we need to implement:
First, we need a variable to collect the individual numbers. This way we can easily check if a number already exists before adding it to the list of selected numbers. We can use an array for this purpose - we will only have a maximum of 4 items in the array and it will not take us too long to check if the array already contains a given number.
One more thing we need to account for is the leading zero. It can happen that that first random digit we get is a 0, but an ineteger cannot start with a 0.
To solve this, we need to specifically check for this case, and then move the 0 to the end of the array before returning the 4 digit number.
The secret number does not really need to be of type number
, since we will use it in the terminal as display value and will not do any calculations with it.
The return value of the function can stay an array
, or we can transform it into string
.
const getSecretNumber = () => {
const digitsCount = 4; // we need 4 digits
const digits = []; // variable to collect digits
// break out of the loop when we reach 4 digits
while (digits.length < digitsCount) {
const number = getRandomNum(0, 9);
// check if number is already inside the digits collection
if (!digits.includes(number)) {
digits.push(number);
}
}
const isFirstDigitZero = digits[0] === 0; // check if 1st digit is a zero
if (isFirstDigitZero) {
// correct leading zero numbers and directly return result
return arr.push(arr.shift()).join("");
}
return digits.join("");
};
While writing the function, run the tests - they should be all passing.
After the tests run, a coverage report table will be printed to the console.
It might be helpful to look in the Uncovered Line #s
column to see which lines are marked as uncovered by the coverage report. It can be a good hint what further tests you need to write.
Looking at the report, we can see that the lines related to correcting leading zero are marked as uncovered. While we do not need to have 100% coverage to have meaningful and useful tests, we can still check if the related lines are something we shoud test.
How can we test the leading zero correction? Since the digits that are generated are random, it might be pretty difficult to get a random number that has a leading zero. In addition it might be difficult to replicate consistently every time we test, so our tests will become unreliable.
If we split the correction bit into a separate function, then we can test it on its own.
We create a function that takes a parameter, and returns the corrected value, if correction needs to be done.
The parameter can be either a string
or array
, it does not really matter in our case.
This way the function becomes testable - we can pass a parameter starting with a 0, and check if we get the expected results.
// correct-leading-zero.test.js
const correctLeadingZero = require("./correct-leading-zero");
it("moves leading zero to the end", () => {
const correctedValue = correctLeadingZero([0, 1, 2, 3]).join("");
const expectedValue = [3, 2, 1, 0].join("");
expect(correctedValue).toBe(expectedValue);
});
it("returns the original array if there is nothing to correct", () => {
const correctedValue = correctLeadingZero([1, 2, 3, 4]).join("");
const expectedValue = [1, 2, 3, 4].join("");
expect(correctedValue).toBe(expectedValue);
});
Now let's write the corresponding functionality:
// correct-leading-zero.js
const correctLeadingZero = arr => {
const isFirstDigitZero = arr[0] === 0;
if (isFirstDigitZero) {
// remove first item and add it as last
return arr.push(arr.shift());
}
return arr;
};
Counting bulls and cows
Next we can work on counting bulls and cows and providing feedback to the user. This functionality has 2 parts:
- counting the number of bulls and cows
- displaying the bulls and cows count to the user
It makes sense then to split this into 2 functions. Let's start with the counting bulls and cows part. We need to create the 2 files - one for the function and one for the tests.
From the rules of the games we already know what counts as a bull and what counts as cows, so let's write some tests.
Let's imagine the secret number is 1234:
- guess 1298 should give us 2 bulls for the digits 1 and 2
- guess 9876 should give us 0 bulls since no digits is in its right position
- guess 9812 should give us 2 cows since 1 and 2 exist but are in the wrong position
- guess 9876 should give us 0 cows since no digits match with the digits in the guess
The counting function needs 2 parameters: the secret number and the user's guess. The return value will be the total of bulls and the total of cows.
We can have 1 function that counts both and returns both values, or we can split this in two functions - one counting only the cows and one counting only the bulls.
Now that we know the function signature we can already write our tests:
const { getBullsCount, getCowsCount } = require("./get-bulls-and-cows-count");
it("returns correct number of bulls", () => {
expect(getBullsCount("1234", "1298")).toBe(2);
});
it("returns 0 when no bulls", () => {
expect(getBullsCount("1234", "9876")).toBe(0);
});
it("returns correct number of cows", () => {
expect(getCowsCount("1234", "9812")).toBe(2);
});
it("returns 0 when no cows", () => {
expect(getCowsCount("1234", "9876")).toBe(0);
});
To get the correct count of bulls, we need to compare both the position and the value.
const getBullsCount = (secretNumber, guessNumber) => {
const secretNumberArray = secretNumber.split("");
const guessNumberArray = guessNumber.split("");
let bullsCount = 0;
secretNumberArray.forEach((_item, index) => {
if (secretNumberArray[index] === guessNumberArray[index]) {
bullsCount++;
}
});
return bullsCount;
};
To count the cows, we need to check if the value exists in the user's guess. We also need to make sure the position is different to avoid having a digit mistakenly marked as both bull and cow.
const getCowsCount = (secretNumber, guessNumber) => {
const secretNumberArray = secretNumber.split("");
const guessNumberArray = guessNumber.split("");
let cowsCount = 0;
secretNumberArray.forEach((item, index) => {
if (
guessNumberArray.includes(item) &&
guessNumberArray.indexOf(item) !== index
) {
cowsCount++;
}
});
return cowsCount;
};
Displaying bulls and cows count
Now that we can get the correct count of bulls and cows, we can move on to formatting the response correctly. Let's create the function file and the test file.
We want to display to a user a complete phrase, something like: Not bad! You got 2 bulls.
.
What if there is only 1 hit? We want the adapt the words to the correct singular form: Not bad! You got 1 bull.
.
When there are both bulls and cows, we want to form one sentence like: Looking good! You have found 2 bulls1 and 1 cows.
.
If the user has no hits, we also need to display an appropriate message.
As always, let's start with writing our tests.
We can assume our function will accept two parameters with the number of bulls and cows respectively, and based on these it returns a string with the formatted text for the user.
const formatBullsAndCowsResponse = require("./get-formatted-bulls-and-cows-reponse.js");
it("prints correct message with 1 bulls", () => {
expect(formatBullsAndCowsResponse(1, 0)).toBe("Not bad! You got 1 bull.");
});
it("prints correct message with many bulls", () => {
expect(formatBullsAndCowsResponse(2, 0)).toBe("Not bad! You got 2 bulls.");
});
it("prints correct message with 1 cow", () => {
expect(formatBullsAndCowsResponse(0, 1)).toBe(
"Getting there! You got 1 cow."
);
});
it("prints correct message with many cows", () => {
expect(formatBullsAndCowsResponse(0, 3)).toBe(
"Getting there! You got 3 cows."
);
});
it("prints correct message both bulls and cows", () => {
expect(formatBullsAndCowsResponse(2, 1)).toBe(
"Looking good! You have found 2 bulls and 1 cow."
);
});
it("prints consolation message when no bulls or cows", () => {
expect(formatBullsAndCowsResponse(0, 0)).toBe(
`Sorry, this was not a good guess I feel.`
);
});
Now writing the functionality:
const formatBullsAndCowsResponse = function (bullsCount, cowsCount) {
if (!bullsCount && !cowsCount) {
// return quickly if there are no hits at all
return `Sorry, this was not a good guess I feel.`;
}
// select singular or plural form
const bulls = `${bullsCount} ${bullsCount === 1 ? "bull" : "bulls"}`;
const cows = `${cowsCount} ${cowsCount === 1 ? "cow" : "cows"}`;
if (bullsCount && cowsCount) {
return `Looking good! You have found ${bulls} and ${cows}.`;
} else if (bullsCount) {
return `Not bad! You got ${bulls}.`;
} else if (cowsCount) {
return `Getting there! You got ${cows}.`;
}
};
Validating user input
One important part is to validate the input provided by the user. The user can enter anything (or nothing at all) and this might break our functionality completely, since we rely on meaningful input to pass to our functions. We need to make few basic checks to sure that input makes sense:
- there must be some input, the user must give a guess
- it should be a valid number with exactly 4 unique digits
Just like the previous function, we will split this in two - one part will be concerned with verifying the input and another with formatting the feedback to display to the user.
We can create 2 files for each - one for the funtion and one for our tests.
The function that verifies the validity of the input will accept one parameter (this is the user input to be validated) and return a list of errors whenever the input is not valid. The list of returned errors can be as detailed as we like. If we want to keep it really simple, we can return a boolean, indicating whenther the input is valid or no. I have chosen to return more detailed feedback with different messages for each error.
Here is the list of errors:
- required: whenever there is an empty input
- unique: whenever digits repeat
- size: whenever input is too long or too short
- number: whenever input is not a valid number
We will need this list of errors for the formatting functionality later too.
const isSecretNumberValid = require("./is-secret-number-valid");
it("verifies empty strings are invalid", () => {
expect(isSecretNumberValid("")).toContain("required");
});
it("verifies inputs with same digits are invalid", () => {
expect(isSecretNumberValid("1111")).toContain("unique");
});
it("verifies too long inputs are invalid", () => {
expect(isSecretNumberValid("14374673434")).toContain("size");
});
it("verifies too short inputs are invalid", () => {
expect(isSecretNumberValid("1")).toContain("size");
});
it("verifies inputs with characters are invalid", () => {
expect(isSecretNumberValid("abcd")).toContain("number");
});
it("verifies inputs with characters are invalid", () => {
expect(isSecretNumberValid("01bc")).toContain("number");
});
it("verifies numbers with same numbers are invalid", () => {
expect(isSecretNumberValid("1234").length).toEqual(0);
});
Since we want to return a list of errors, we will need a place to collect the errors. We can use an array for this purpose. The function will then return the list of errors. The function can return multiple errors, but the returned errors list should still make sense. For example, when there is no input at all, there is no point to also return errors that the input is too short or that it is not a valid number.
const isSecretNumberValid = guess => {
const errors = [];
// A guess must be provided
if (!guess.trim()) {
errors.push("required");
}
// The guess must be a number
if (guess && isNaN(Number(guess))) {
errors.push("number");
}
// Must have 4 digits
if (guess && guess.length !== 4) {
errors.push("size");
}
// The guess number must have 4 different digits
if (guess && guess.length === 4) {
const uniqueDigits = new Set(guess);
if (Array.from(uniqueDigits).length !== 4) {
errors.push("unique");
}
}
return errors;
};
Displaying validation errors
Now that we have a list of validation errors, we can use them to display a message to the user. We can create an object, where the validation errors (from the list above) are the keys, and the values are the text messages to be displayed. This way we can quickly extract which message to display. Since we can have multiple errors, we need to combine the individual messages in one longer message.
const errors = {
required: "Please enter a guess.",
size: "Must have 4 digits.",
number: "Please enter a valid number.",
unique: "All digits must be unique."
};
Let's create 2 files - one for the function and one for the tests.
const getFormattedErrorsMessage = require("./get-formatted-errors-message");
const errors = {
required: "Please enter a guess.",
size: "Must have 4 digits.",
number: "Please enter a valid number.",
unique: "All digits must be unique."
};
it("displays required error", () => {
const errorsList = getFormattedErrorsMessage(["required"]); // ["required"] is the list of validation errors, which we will get from the function we defined previously
expect(errorsList).toContain(errors.required); // check if the message matches with the one returned
});
it("displays size error", () => {
const errorsList = getFormattedErrorsMessage(["size"]);
expect(errorsList).toContain(errors.size);
});
it("displays number error", () => {
const errorsList = getFormattedErrorsMessage(["number"]);
expect(errorsList).toContain(errors.number);
});
it("displays unique error", () => {
const errorsList = getFormattedErrorsMessage(["unique"]);
expect(errorsList).toContain(errors.unique);
});
The functionlity will look something like this:
const getFormattedErrorsMessage = (errors = []) => {
const errors = {
required: "Please enter a guess.",
size: "Must have 4 digits.",
number: "Please enter a valid number.",
unique: "All digits must be unique."
};
const message = [];
if (errors.includes("required")) {
message.push(errors["required"]);
}
if (errors.includes("number")) {
message.push(errors["number"]);
}
if (errors.includes("size")) {
message.push(errors["size"]);
}
if (errors.includes("unique")) {
message.push(errors["unique"]);
}
return message.join(" ");
};
Putting it all together
Now we have the individual pieces and what is left is putting them all together.
Getting user input
In the browser environment we have the built-in prompt
command. In Node.js though we need to use a package to get the same functionality. We will use a package called prompt-sync
so we don't have to deal with asyncronous code at this point.
Let's install the package:
npm i prompt-sync
Next let's add it to the index.js file:
const promptLib = require("prompt-sync");
const prompt = promptLib({ sigint: true });
promptLib
includes the library, then we need to initialize it
by passing options that allow us to terminate the process by pressing ctrl + c.
The prompt
variable is a function. It accepts a parameter which is the question that will be displayed to the user whenever we call the function. The prompt
function will return the input the user entered.
Let's add a simple prompt to see how it works. You can run the file with node index.js
.
For example, let's try to get the user name:
const name = prompt("Name? ") || "Stranger";
console.log(name);
Now when running the file, you should get a prompt with the text Name?
. The variable name will become equal to the value you entered. If you did not type anything and just hit enter, the name will be set to the default value, which is Stranger
.
Next we would need to start a loop, that will keep going until the user has guessed the number correctly. We can create a boolean variable and use it as a flag to indicate when the reached the condition to break out of the loop:
const isGameOn = true;
while (isGameOn) {
const guess = prompt("Say your guess ");
}
Now this looks like an infinite loop - and it is!
You can still run the program with node index.js
- it should keep asking for a guess, and you can always terminate with cmd + C
.
Note that you can enter any text you like, you can also leave it empty. We will need to add the validation we wrote earlier so the user inputs only what is allowed - 4 digit number with all unique digits - but we will come back to this in a bit.
First, we need to generate a secret number with the function we created earlier. Next, we need to add a condition to break out of the loop when the user guess matches the secret number.
const promptLib = require("prompt-sync");
const getSecretNumber = require("./get-secret-number");
const play = () => {
const prompt = promptLib({ sigint: true });
const name = prompt("Name? ") || "Stranger";
const secretNumber = getSecretNumber();
let isGameOn = true;
// TODO: Only for debugging, don't forget to delete
console.log(`The secret number is ${secretNumber}`);
while (isGameOn) {
const guess = prompt("Say your guess ");
if (secretNumber === guess) {
console.log(`Congratulations ${name}!`);
isGameOn = false;
}
}
};
module.exports = play;
We have logged the secret number to the console so we know it, and we can more easily quit the game.
While developing the game, we would need to play many rounds to verify our program works as expected. Instead we will write tests that do this for us. We have tested each individual unit, we now we want to test how they work all together.
Until now whenever we wrote tests, we would call the function we wanted to test, and verify the expected return value.
We have created a play
function but some things might be difficult to test.
The play
function does not have a return value, instead it executes multiple different functions, and what we need to verify the different combinations.
To understand what to test, we can think what we would verify if we were testing things manually (playing the game ourselves):
- the user is asked for their name and then it is used when providing feedback to the user
- a default name is set when user does not provide a name
- the game ends when secret number is guessed
- the user is asked for a guess until they guess the secret number
- congratulations message is displayed
The play
function is just a regular function, and all its internal variables are gone after its return.
This makes it difficult for us to check for example if the name is set correctly.
We could create a closure, or alternatively we can use a class. Let's go with a class.
Let's create a file called game.js
and the corresponding test file.
When initializing the class, we want to get the user name, get the secret number and turn the game flag on.
class Game {
constructor() {
this.prompt = promptLib({ sigint: true }); // assign the prompt function to an internal class property
this.name = this.prompt("Name? ") || "Stranger";
this.secretNumber = getSecretNumber();
this.isGameOn = true;
this.errors = [];
}
}
We also want to save the prompt into a class method so we can test it more easily later.
For the testing, this time we need to do a bit more setup in the test file as we need to mock the prompt library and the function that gets the secret number.
We need to mock the prompt library so we can simulate the user input in our tests (there is no user who will provide input), and be able to set it to whatever value we need for the particular test.
The getSecretNumber
number we need to mock so we get a predictable secret number in order for our tests to pass. We already have a test that verifies that the getSecretNumber
works as expected, here we want to test the entire flow.
const Game = require("./game");
// Mock dependecies
jest.mock("prompt-sync");
jest.mock("./get-secret-number");
const prompt = require("prompt-sync");
const getSecretNumber = require("./get-secret-number");
We first create mocks, then when we require the modules. This way our mocks will be used instead of the real modules.
The getSecretNumber
generates a random number, but for our tests we want to return always the same secret number.
We can do this in two ways - either mocking the function implementation or by mocking the return value:
// mocking the function implementation
getSecretNumber.mockImplementation(() => "1234");
// or mocking the function return value
getSecretNumber.mockReturnValue("1234");
With mockImplementation
we need to create a new function body that will be executed whenever the function is called.
Alternatively, we can use mockReturnValue
which will give us the value that will be returned whenever the function is called.
For the getSecretNumber
we can use directly mockReturnValue
.
For the prompt though, we need to use the mockImplementation
since we need to return different values for the different questions.
prompt.mockImplementation(() => {
return jest.fn(ask => {
if (ask === "Name? ") {
return "Jane";
}
if (ask === "Take a guess ") {
return "1234";
}
return null;
});
});
When we call the mock library we get a function back, that accept one parameter (the question for the user) and return the value the user entered. Our mock implementation will simulate this by checking for the same questions and returning our hard coded user responses.
Now let's write our first test to check if the user name is set correctly:
const Game = require("./game");
// Mock dependecies
jest.mock("prompt-sync");
jest.mock("./get-secret-number");
const prompt = require("prompt-sync");
const getSecretNumber = require("./get-secret-number");
describe("game works as expeceted", () => {
beforeEach(() => {
// getSecretNumber.mockImplementation(() => '1234');
getSecretNumber.mockReturnValue("1234");
});
afterEach(() => {
jest.clearAllMocks();
});
it("asks user for name and guess", () => {
prompt.mockImplementation(() => {
return jest.fn(ask => {
if (ask === "Name? ") {
return "Jane";
}
if (ask === "Take a guess ") {
return "1234";
}
return null;
});
});
const game = new Game();
expect(game.prompt).toHaveBeenCalledTimes(1);
expect(game.prompt).toHaveBeenCalledWith(`Name? `);
expect(game.name).toBe(`Jane`);
});
it("sets name to Stranger when there is no input", () => {
prompt.mockImplementation(() => {
return jest.fn(ask => {
if (ask === "Name? ") {
return "";
}
if (ask === "Take a guess ") {
return "1234";
}
return null;
});
});
const game = new Game();
expect(game.prompt).toHaveBeenCalledTimes(1);
expect(game.prompt).toHaveBeenCalledWith(`Name? `);
expect(game.name).toBe("Stranger");
});
});
Looking good. Now we want to add the play
method on the Game
class.
const promptLib = require("prompt-sync");
const getSecretNumber = require("./get-secret-number");
const isSecretNumberValid = require("./is-secret-number-valid");
const getFormattedErrorsMessage = require("./get-formatted-errors-message");
const formatBullsAndCowsResponse = require("./get-formatted-bulls-and-cows-reponse");
const { getBullsCount, getCowsCount } = require("./get-bulls-and-cows-count");
class Game {
constructor() {
this.prompt = promptLib({ sigint: true });
this.name = this.prompt("Name? ") || "Stranger";
this.secretNumber = getSecretNumber();
this.isGameOn = true;
this.errors = [];
}
play() {
while (this.isGameOn) {
const guess = this.prompt("Take a guess ");
// Validation
this.errors = isSecretNumberValid(guess);
if (this.errors.length) {
const errorMessage = getFormattedErrorsMessage(this.errors);
console.log(errorMessage);
continue;
}
if (this.secretNumber === guess) {
console.log(`Congratulations ${this.name}!`);
this.isGameOn = false;
} else {
const bullsCount = getBullsCount(this.secretNumber, guess);
const cowsCount = getCowsCount(this.secretNumber, guess);
const message = formatBullsAndCowsResponse(bullsCount, cowsCount);
console.log(message);
}
}
}
}
module.exports = Game;
We want to add few tests for the play method and we want to verify the user gets a congratulations message when they guess the secret number.
it("congratulates user with correct name when guess is correct", () => {
prompt.mockImplementation(() => {
return jest.fn(ask => {
if (ask === "Name? ") {
return "Jane";
}
if (ask === "Take a guess ") {
return "1234";
}
return null;
});
});
const consoleSpy = jest.spyOn(console, "log");
const game = new Game();
game.play();
// expect(game.prompt).toHaveBeenCalledWith(`Take a guess `);
expect(consoleSpy).toHaveBeenCalledWith(`Congratulations Jane!`);
});
We want to verify that the user will be prompted to provide another guess until they guess the secret number.
To do this we will need to update the mock implementation of the prompt function.
In the previous implementation we could only ask 2 times the user, once for the name and once for a guess, the guess was correct and the game was over. Now we need to simulate multiple user inputs for the guess.
We can do this by adding a fake counter:
prompt.mockImplementation(() => {
let count = 1;
return jest.fn(ask => {
if (ask === "Name? ") {
return "Jane";
}
if (ask === "Take a guess " && count === 1) {
count++;
return "9876"; // provide wrong answer
}
if (ask === "Take a guess " && count === 2) {
count++;
return "8765"; // provide wrong answer again
}
if (ask === "Take a guess " && count === 3) {
return "1234"; // provide the right answer
}
return null;
});
});
This way we simulate the user giving 2 times wrong answers, and finally guessing the correct number. We can verify that that user has been asked 3 times to provide and answer.
it("keeps asking user for guess until secret number is guessed", () => {
prompt.mockImplementation(() => {
let count = 1;
return jest.fn(ask => {
if (ask === "Name? ") {
return "Jane";
}
if (ask === "Take a guess " && count === 1) {
count++;
return "9876";
}
if (ask === "Take a guess " && count === 2) {
count++;
return "8765";
}
if (ask === "Take a guess " && count === 3) {
return "1234";
}
return null;
});
});
const game = new Game();
game.play();
expect(game.prompt).toHaveBeenCalledWith(`Name? `);
expect(game.prompt).toHaveBeenCalledWith(`Take a guess `);
expect(game.prompt).toHaveBeenCalledTimes(4); // 3 for guess, and 1 for name
});
We can use the same idea to verify the user receives the number of bulls and cows, and the errors messages.
it("displays errors with wrong user input", () => {
prompt.mockImplementation(() => {
// Use count value to simulate different user inputs
let count = 1;
return jest.fn(ask => {
if (ask === "Name? ") {
return "Jane";
}
if (ask === "Take a guess " && count === 1) {
count++;
return "";
}
if (ask === "Take a guess " && count !== 1) {
return "1234";
}
return null;
});
});
const consoleSpy = jest.spyOn(console, "log");
const game = new Game();
game.play();
expect(consoleSpy).toHaveBeenCalledWith(`Please enter a guess.`);
});
it("displays bulls and cows count", () => {
prompt.mockImplementation(() => {
// Use count value to simulate different user inputs
let count = 1;
return jest.fn(ask => {
if (ask === "Name? ") {
return "Jane";
}
if (ask === "Take a guess " && count === 1) {
count++;
return "1324";
}
if (ask === "Take a guess " && count > 1) {
return "1234";
}
return null;
});
});
const consoleSpy = jest.spyOn(console, "log");
const game = new Game();
game.play();
expect(consoleSpy).toHaveBeenCalledWith(
`Looking good! You have found 2 bulls and 2 cows.`
);
});
Looking at the report, we have reached a good level of coverage. We do not need to do any manual testing and we can rely on our tests with confidence.
Conclusion
We have developed a basic version of the bulls and cows game. We used test driven development concepts. While working through the game development, we wrote tests first and refactor our code to make it more testable. Testing can lead to writing better code, and we ended up with more modular functions.
There are many extra features you can add to make the game more interesting, here are a few:
- cound the attempts the user needs to take to win
- give a maximum guess number
- ask user to play a second round
- display color output in the terminal
- at end of game display total count for all rounds