/* 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 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 _expectedInputOutputSteps = new ArrayList(); private Iterator _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 + "')"; } }