/*
GuessTheNumber.java = simple “guess the number” game
tests/GuessTheNumberTest.java = JUnit test for the game
tests/SystemInputOutputTesterTest.java = test the main JUnit testing factory
library/RandomSource.java = creates "Random" instances (or mock) for the Program Under Test
library/RandomCreatorInterface.java = interface to create "Random" instances (enables lambda expressions)
library/AbstractMockRandomSwapper.java = swaps mock Random subclasses in on construction and out on close
library/MockRandomSingleValue.java = mock implementation for single-use random number
library/SystemInputOutputTester.java = Builder for console I/O testing
library/MockStandardOutputStream.java = Capture Standard Output (or Error)
library/MockInputStream.java = Provide input when needed. Calls back to SystemInputOutputTester to validate intervening output and to provide the input data.
library/ExpectedInputOutputStepBase.java = common base class for input/output expectations
library/ExpectedTextOutputStep.java = Expect that the given text output is written to Standard Output (or Error).
library/ProvideLineInputStep.java = Provide line of Console Input at that point in the expectations list.
*/
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// File Name: GuessTheNumber.java
//
package p20230510_TestConsoleIO;
import p20230510_TestConsoleIO.library.RandomSource;
import java.util.Scanner;
public class GuessTheNumber {
public static void main(final String[] args) {
System.out.println("Welcome to the number guessing program.");
System.out.println("I am thinking of a number in the range of 1 to 100, inclusive.");
System.out.println("You need to guess this number, with a minimum number of guesses.");
System.out.println("Each time you guess, I will tell you if my number is HIGHER or LOWER than your guess.");
final var random = RandomSource.create();
final int numberToGuess = random.nextInt(100);
final var scanner = new Scanner(System.in);
var numberOfGuesses = 0;
while (true) {
System.out.println("What is your guess?");
final var userGuess = scanner.nextInt();
++numberOfGuesses;
if (userGuess < numberToGuess) {
System.out.println("Your guess of " + userGuess + " is TOO LOW.");
} else if (userGuess > numberToGuess) {
System.out.println("Your guess of " + userGuess + " is TOO HIGH.");
} else {
System.out.println("Your guess of " + userGuess + " is CORRECT in "
+ (numberOfGuesses == 1 ? "one guess!!!" : (numberOfGuesses + " guesses!")));
break;
}
}
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// File Name: tests/GuessTheNumberTest.java
//
package p20230510_TestConsoleIO.tests;
import org.junit.Test;
import p20230510_TestConsoleIO.GuessTheNumber;
import p20230510_TestConsoleIO.library.MockRandomSingleValue;
import p20230510_TestConsoleIO.library.SystemInputOutputTester;
import java.io.IOException;
public class GuessTheNumberTest {
@Test
public void testHighsAndLowsTo33() throws IOException {
final var systemNumberWeAreTryingToGuess = 33;
try (final var mockRandomNumberGenerator = new MockRandomSingleValue(systemNumberWeAreTryingToGuess)) {
SystemInputOutputTester
.whenCallingThisMethod(() -> {
GuessTheNumber.main(null);
})
.assertTextOutput("Welcome to the number guessing program.")
//
.assertTextOutput("I am thinking of a number in the range of 1 to 100, inclusive.")
.assertTextOutput("You need to guess this number, with a minimum number of guesses.")
.assertTextOutput("Each time you guess, I will tell you if my number is HIGHER or LOWER than your guess.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("50")
.assertTextOutput("Your guess of 50 is TOO HIGH.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("25")
.assertTextOutput("Your guess of 25 is TOO LOW.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("37")
.assertTextOutput("Your guess of 37 is TOO HIGH.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("31")
.assertTextOutput("Your guess of 31 is TOO LOW.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("34")
.assertTextOutput("Your guess of 34 is TOO HIGH.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("32")
.assertTextOutput("Your guess of 32 is TOO LOW.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("33")
.assertTextOutput("Your guess of 33 is CORRECT in 7 guesses!")
//
.assertThatTheMethodReturnsHere();
}
}
@Test
public void testMaximumGuesses() throws IOException {
final var systemNumberWeAreTryingToGuess = 100;
try (final var mockRandomNumberGenerator = new MockRandomSingleValue(systemNumberWeAreTryingToGuess)) {
SystemInputOutputTester
.whenCallingThisMethod(() -> {
GuessTheNumber.main(null);
})
.assertTextOutput("Welcome to the number guessing program.")
//
.assertTextOutput("I am thinking of a number in the range of 1 to 100, inclusive.")
.assertTextOutput("You need to guess this number, with a minimum number of guesses.")
.assertTextOutput("Each time you guess, I will tell you if my number is HIGHER or LOWER than your guess.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("50")
.assertTextOutput("Your guess of 50 is TOO LOW.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("75")
.assertTextOutput("Your guess of 75 is TOO LOW.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("88")
.assertTextOutput("Your guess of 88 is TOO LOW.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("94")
.assertTextOutput("Your guess of 94 is TOO LOW.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("97")
.assertTextOutput("Your guess of 97 is TOO LOW.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("99")
.assertTextOutput("Your guess of 99 is TOO LOW.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("100")
.assertTextOutput("Your guess of 100 is CORRECT in 7 guesses!")
//
.assertThatTheMethodReturnsHere();
}
}
@Test
public void testFirstGuessIsCorrect() throws IOException {
final var systemNumberWeAreTryingToGuess = 24;
try (final var mockRandomNumberGenerator = new MockRandomSingleValue(systemNumberWeAreTryingToGuess)) {
SystemInputOutputTester
.whenCallingThisMethod(() -> {
GuessTheNumber.main(null);
})
.assertTextOutput("Welcome to the number guessing program.")
//
.assertTextOutput("I am thinking of a number in the range of 1 to 100, inclusive.")
.assertTextOutput("You need to guess this number, with a minimum number of guesses.")
.assertTextOutput("Each time you guess, I will tell you if my number is HIGHER or LOWER than your guess.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("24")
.assertTextOutput("Your guess of 24 is CORRECT in one guess!!!")
//
.assertThatTheMethodReturnsHere();
}
}
@Test
public void testSecondGuessIsCorrect() throws IOException {
final var systemNumberWeAreTryingToGuess = 33;
try (final var mockRandomNumberGenerator = new MockRandomSingleValue(systemNumberWeAreTryingToGuess)) {
SystemInputOutputTester
.whenCallingThisMethod(() -> {
GuessTheNumber.main(null);
})
.assertTextOutput("Welcome to the number guessing program.")
//
.assertTextOutput("I am thinking of a number in the range of 1 to 100, inclusive.")
.assertTextOutput("You need to guess this number, with a minimum number of guesses.")
.assertTextOutput("Each time you guess, I will tell you if my number is HIGHER or LOWER than your guess.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("86")
.assertTextOutput("Your guess of 86 is TOO HIGH.")
//
.assertTextOutput("What is your guess?")
.provideLineInput("33")
.assertTextOutput("Your guess of 33 is CORRECT in 2 guesses!")
//
.assertThatTheMethodReturnsHere();
}
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// File Name: tests/SystemInputOutputTesterTest.java
//
package p20230510_TestConsoleIO.tests;
import junit.framework.AssertionFailedError;
import org.junit.Assert;
import org.junit.Test;
import p20230510_TestConsoleIO.library.SystemInputOutputTester;
public class SystemInputOutputTesterTest {
private static class DoNothingProgram {
public static void main(String[] args) {
// Intentionally do NOTHING here.
}
}
@Test
public void testDoNothingProgram() {
SystemInputOutputTester
.whenCallingThisMethod(() -> {
DoNothingProgram.main(null);})
.assertThatTheMethodReturnsHere();
}
@Test
public void testMissingMessage() {
try {
SystemInputOutputTester
.whenCallingThisMethod(() -> {
DoNothingProgram.main(null);})
.assertTextOutput("This program does not and should not produce this output.")
.assertThatTheMethodReturnsHere();
throw new ExpectedException("Expected AssertionFailedError to be thrown by the code above.");
} catch (final AssertionFailedError ex) {
Assert.assertEquals("Failed to find <This program does not and should not produce this output.> in <>", ex.getMessage());
}
}
@Test
public void testProgramTerminatesWithMoreInputToProcess() {
try {
SystemInputOutputTester
.whenCallingThisMethod(() -> {
DoNothingProgram.main(null);})
.provideLineInput("Unused Input Line")
.assertThatTheMethodReturnsHere();
throw new ExpectedException("Expected AssertionFailedError to be thrown by the code above.");
} catch (final AssertionFailedError ex) {
Assert.assertEquals("Program has exited, but the tests say that we should further input: ProvideLineInputStep('Unused Input Line')", ex.getMessage());
}
}
@Test
public void testHelloWorldProgram() {
SystemInputOutputTester
.whenCallingThisMethod(() -> {
HelloWorldProgram.main(null);})
.assertTextOutput("Hello world!")
.assertThatTheMethodReturnsHere();
}
private static class HelloWorldProgram {
public static void main(String[] args) {
System.out.println("Hello world!");
}
}
private static class ExpectedException extends Error {
public ExpectedException(final String message) {
super(message);
}
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// File Name: library/RandomSource.java
//
package p20230510_TestConsoleIO.library;
import p20230510_TestConsoleIO.GuessTheNumber;
import java.util.Random;
abstract public class RandomSource {
private RandomSource() {
throw new IllegalStateException("Not expecting to create instances of this class.");
}
private static RandomCreatorInterface _creator = () -> {
return new Random();
};
public static Random create() {
return _creator.create();
}
public static RandomCreatorInterface swapRandomConstructor(final RandomCreatorInterface newRandomCreator) {
final var oldRandomCreator = _creator;
_creator = newRandomCreator;
return oldRandomCreator;
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// File Name: library/RandomCreatorInterface.java
//
package p20230510_TestConsoleIO.library;
import java.util.Random;
public interface RandomCreatorInterface {
Random create();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// File Name: library/AbstractMockRandomSwapper.java
//
package p20230510_TestConsoleIO.library;
import java.io.Closeable;
import java.io.IOException;
import java.util.Random;
abstract public class AbstractMockRandomSwapper extends Random implements Closeable {
private final RandomCreatorInterface _oldRandomConstructor;
protected AbstractMockRandomSwapper() {
_oldRandomConstructor = RandomSource.swapRandomConstructor(() -> {
return this;
});
}
@Override
public void close() throws IOException {
final var randomConstructorSwappedOut = RandomSource.swapRandomConstructor(_oldRandomConstructor);
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// File Name: library/MockRandomSingleValue.java
//
package p20230510_TestConsoleIO.library;
public class MockRandomSingleValue extends AbstractMockRandomSwapper {
private int _theFixedRandomNumberValue;
public MockRandomSingleValue(final int theFixedRandomNumberValue) {
_theFixedRandomNumberValue = theFixedRandomNumberValue;
}
@Override
public int nextInt(final int bound) {
final var returnValue = _theFixedRandomNumberValue;
_theFixedRandomNumberValue = -1; // Clear value, as it's only good for one use.
return returnValue;
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// File Name: library/SystemInputOutputTester.java
//
package p20230510_TestConsoleIO.library;
import junit.framework.AssertionFailedError;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class SystemInputOutputTester {
private final Runnable _callback;
private final List<ExpectedInputOutputStepBase> _expectedInputOutputSteps = new ArrayList<ExpectedInputOutputStepBase>();
private Iterator<ExpectedInputOutputStepBase> _expectedInputOutputStepIterator = null;
private final MockInputStream _mockInputStream = new MockInputStream(this);
private final MockStandardOutputStream _mockStandardOutputStream = new MockStandardOutputStream();
//protected final MockStandardOutputStream _mockStandardErrorStream = new MockStandardOutputStream(this);
public SystemInputOutputTester(final Runnable callback) {
_callback = callback;
}
public static SystemInputOutputTester whenCallingThisMethod(final Runnable callback) {
return new SystemInputOutputTester(callback);
}
public SystemInputOutputTester assertTextOutput(final String expectedOutputMessage) {
_expectedInputOutputSteps.add(new ExpectedTextOutputStep(_mockStandardOutputStream, expectedOutputMessage));
return this;
}
public SystemInputOutputTester provideLineInput(final String lineOfInputText) {
_expectedInputOutputSteps.add(new ProvideLineInputStep(lineOfInputText));
return this;
}
public void assertThatTheMethodReturnsHere() {
final var oldInput = System.in;
final var oldOutput = System.out;
//final var oldError = System.err;
try {
System.setIn(_mockInputStream);
System.setOut(new PrintStream(_mockStandardOutputStream));
//System.setErr(new PrintStream(_mockStandardErrorStream));
_expectedInputOutputStepIterator = _expectedInputOutputSteps.iterator();
_callback.run();
System.out.flush();
System.err.flush();
while (_expectedInputOutputStepIterator.hasNext()) {
final var step = _expectedInputOutputStepIterator.next();
final var isAfterExit = true;
final var didProvideNewUserInput = step.validate(this);
if (didProvideNewUserInput) {
throw new AssertionFailedError(
"Program has exited, but the tests say that we should further input: " + step.toString());
}
}
} finally {
System.setIn(oldInput);
System.setOut(oldOutput);
//System.setErr(oldError);
}
}
public void validateToAndIncludingNextInput() {
System.out.flush();
System.err.flush();
while (_expectedInputOutputStepIterator.hasNext()) {
final var step = _expectedInputOutputStepIterator.next();
final var isAfterExit = false;
final var didProvideNewUserInput = step.validate(this);
if (didProvideNewUserInput) {
break;
}
}
}
protected void provideLineOfInput(final String lineOfInputText) {
_mockInputStream.provideLineOfInput(lineOfInputText);
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// File Name: library/MockStandardOutputStream.java
//
package p20230510_TestConsoleIO.library;
import org.jetbrains.annotations.NotNull;
import org.junit.Assert;
import java.io.IOException;
import java.io.OutputStream;
public class MockStandardOutputStream extends OutputStream {
private final StringBuilder _sequenceOfByteValues = new StringBuilder();
private final StringBuilder _accumulatedOutput = new StringBuilder();
@Override
public void write(final int byteValue) throws IOException {
_sequenceOfByteValues.append((char) byteValue);
}
public StringBuilder getUpdatedStringBuilder() {
if (_sequenceOfByteValues.length() > 0) {
final String stringValue = getStringValue();
_accumulatedOutput.append(stringValue);
}
return _accumulatedOutput;
}
@NotNull
private String getStringValue() {
final var bytes = new byte[_sequenceOfByteValues.length()];
final var charArray = _sequenceOfByteValues.toString().toCharArray();
Assert.assertEquals(bytes.length, charArray.length);
for (int idx = 0; idx < bytes.length; ++idx) {
bytes[idx] = (byte) charArray[idx];
}
_sequenceOfByteValues.setLength(0);
final var stringValue = new String(bytes);
return stringValue;
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// File Name: library/MockInputStream.java
//
package p20230510_TestConsoleIO.library;
import java.io.IOException;
import java.io.InputStream;
public class MockInputStream extends InputStream {
private final SystemInputOutputTester _systemInputOutputTester;
private final StringBuilder _inputToProvide = new StringBuilder();
public MockInputStream(final SystemInputOutputTester systemInputOutputTester) {
_systemInputOutputTester = systemInputOutputTester;
}
@Override
public int read() throws IOException {
if (_inputToProvide.length() == 0) {
_systemInputOutputTester.validateToAndIncludingNextInput();
}
if (_inputToProvide.length() == 0) {
return -1; // = End Of File
} else {
final var firstCharacter = _inputToProvide.charAt(0);
_inputToProvide.delete(0, 1);
return firstCharacter;
}
}
@Override
public int read(final byte byteArray[], final int offset, final int length) throws IOException {
if (_inputToProvide.length() == 0) {
_systemInputOutputTester.validateToAndIncludingNextInput();
}
if (_inputToProvide.length() == 0) {
return -1; // = End Of File
} else {
int charsRead = 0;
for (; charsRead < length && _inputToProvide.length() > 0; ++charsRead) {
final var firstCharacter = _inputToProvide.charAt(0);
_inputToProvide.delete(0, 1);
byteArray[offset + charsRead] = (byte) firstCharacter;
}
return charsRead;
}
}
public void provideLineOfInput(final String lineOfInputText) {
_inputToProvide.append(lineOfInputText);
_inputToProvide.append('\n');
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// File Name: library/ExpectedInputOutputStepBase.java
//
package p20230510_TestConsoleIO.library;
import junit.framework.AssertionFailedError;
abstract public class ExpectedInputOutputStepBase {
protected final AssertionFailedError _locationOfAssertionInitialization = new AssertionFailedError(
"Location of assertion initialization. [Look up in the Test's chained method calls.]");
protected ExpectedInputOutputStepBase() {
}
public abstract boolean validate(final SystemInputOutputTester systemInputOutputTester);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// File Name: library/ExpectedTextOutputStep.java
//
package p20230510_TestConsoleIO.library;
import junit.framework.AssertionFailedError;
public class ExpectedTextOutputStep extends ExpectedInputOutputStepBase {
private final MockStandardOutputStream _mockOutputStream;
private final String _expectedOutputMessage;
public ExpectedTextOutputStep(final MockStandardOutputStream mockOutputStream, final String expectedOutputMessage) {
_mockOutputStream = mockOutputStream;
_expectedOutputMessage = expectedOutputMessage;
}
@Override
public boolean validate(final SystemInputOutputTester systemInputOutputTester) {
final var stringBuilder = _mockOutputStream.getUpdatedStringBuilder();
final var currentStringValue = stringBuilder.toString();
final var foundAtIndex = currentStringValue.indexOf(_expectedOutputMessage);
if (foundAtIndex >= 0) {
// Remove the string value we just found:
stringBuilder.delete(0, foundAtIndex + _expectedOutputMessage.length());
// If followed immediately by a new line, remove that too:
final var newlineCharacterSequence = System.lineSeparator();
if (stringBuilder.toString().startsWith(newlineCharacterSequence)) {
stringBuilder.delete(0, newlineCharacterSequence.length());
}
return false; // Did NOT provide new *input* data.
} else {
final var remainingOutputWithNewlinesVisible = currentStringValue
.replace("\r\n", "[NL]")
.replace("\r", "\\r")
.replace("\n", "\\n");
final var failEx = new AssertionFailedError("Failed to find <" + _expectedOutputMessage + "> in <" + remainingOutputWithNewlinesVisible + ">");
failEx.initCause(super._locationOfAssertionInitialization);
throw failEx;
}
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// File Name: library/ProvideLineInputStep.java
//
package p20230510_TestConsoleIO.library;
public class ProvideLineInputStep extends ExpectedInputOutputStepBase {
private final String _lineOfInputText;
public ProvideLineInputStep(final String lineOfInputText) {
_lineOfInputText = lineOfInputText;
}
@Override
public boolean validate(final SystemInputOutputTester systemInputOutputTester) {
systemInputOutputTester.provideLineOfInput(_lineOfInputText);
return true; // *DID* provide new *input* data.
}
@Override
public String toString() {
return "ProvideLineInputStep('" + _lineOfInputText + "')";
}
}
Comments
0 B
|👍
/👎