In this post, I will create a simple function that will allow you to ask the user a question, receive a valid response, and return it as a Boolean type. And if needed, the user will be able to tap Ctrl+C at any time to exit the program. Also importantly, despite using the operating system input, we will create unit tests for os.stdin.
go mod init devtidbits.com/yn
Firstly create a new function in the main.go
file called YesNo that takes a string as an argument and returns a boolean value.
package main
func main() {}
func YesNo(s string) bool {
return false
}
Now let’s call the YesNo function by asking a question and printing the returned boolean value. At the moment, this will always be false.
func main() {
b := YesNo("Is the weather good today?")
fmt.Println(b)
}
func YesNo(s string) bool {
fmt.Printf("%s ", s)
return false
}
Create a function named Read that will take an io.Reader argument and return a rune and error value. This function is to read the user’s keyboard input as the first Unicode rune rather than the first byte.
// Read and return the first rune from the reader.
func Read(r io.Reader) (rune, error) {
reader := bufio.NewReader(r)
ru, _, err := reader.ReadRune()
return ru, err
}
Create a new Parse function to read the rune and return the appropriate boolean or an error.
var ErrParse = errors.New("invalid keypress, it does not match a boolean")
// Parse the rune as a bool.
// Characters y or Y return true, n or N return false.
// Everything else returns an error.
func Parse(r rune) (bool, error) {
switch unicode.ToLower(r) {
case rune('y'):
return true, nil
case rune('n'):
return false, nil
}
return false, ErrParse
}
Update the YesNo function to read and parse the standard input. It also now prints the expected keyboard input.
// YesNo prints s requesting a boolean yes-or-no answer from the stardard input.
func YesNo(s string) bool {
fmt.Printf("%s [y/n] ", s)
input, err := Read(os.Stdin)
if err != nil {
log.Fatalln(err)
}
r, _ := Parse(input)
return r
}
go run .
Is the weather good today? [y/n] y
true
go run .
Is the weather good today? [y/n] n
false
Instead of manually inputting various keys to test the results, let’s automate it with unit tests. Create a main_test.go
file with a package main_test.
package main_test
import (
"io"
"testing"
main "devtidbits.com/yn"
)
func TestRead(t *testing.T) {
var stdin bytes.Buffer
tests := []struct {
name string
keypress string
want rune
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stdin.WriteString(tt.keypress)
got, err := main.Read(&stdin)
if (err != nil) != tt.wantErr {
t.Errorf("Read() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Read() = %v, want %v", got, tt.want)
}
})
}
}
The “devtidbits.com/yn” module in the go.mod
file is imported as main. The TestRead function is a boilerplate created by VSCode, and I’ve then modified it to simulate a standard input keypress.
Replace // TODO: Add test cases. with our test arguments.
}{
{"empty", "", 0, true},
{"newline", "\n", rune('\n'), false},
{"space", " ", rune(' '), false},
{"upper y", "Y", rune('Y'), false},
{"lower y", "y", rune('y'), false},
{"multibyte", "😃", rune('😃'), false},
{"Yes", "Yes", rune('Y'), false},
}
The “empty” test returns an error because stdin cannot be null and always returns a value.
The “newline” and “space” tests confirm whitespace inputs work as expected. A newline control is the return value for the Enter or Return keypresses.
The “upper y” and “lower y” tests confirm case matching.
The “multibyte” test confirms characters larger than a single byte work correctly.
And the “Yes” test shows that only the first character of the standard input is ever read and returned.
Create a TestParse for the Parse function, mainly using the same TestRead cases. This test will be different though as incorrect inputs return errors.
func TestParse(t *testing.T) {
tests := []struct {
name string
r rune
want bool
wantErr bool
}{
// incorrect values
{"empty", 0, false, true},
{"newline", rune('\n'), false, true},
{"space", rune(' '), false, true},
{"multibyte", rune('😃'), false, true},
// correct values
{"Yes", rune('Y'), true, false},
{"yes", rune('y'), true, false},
{"No", rune('N'), false, false},
{"no", rune('n'), false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := main.Parse(tt.r)
if (err != nil) != tt.wantErr {
t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Parse() = %v, want %v", got, tt.want)
}
})
}
}
Don’t forget to run the tests.
go test .
ok devtidbits.com/yn 0.002s
Bonus, limit the keypresses.
Currently, YesNo returns false for all keypresses except for Y and y. Assuming no for all other keys is generally bad for usability and accidental keypresses. Instead, YesNo should only work with the n, N, y, Y, keys. Otherwise, remind the user of the question.
// YesNo prints s requesting a boolean yes-or-no answer from the stardard input.
func YesNo(s string) bool {
for {
fmt.Printf("%s [y/n] ", s)
input, err := Read(os.Stdin)
if err != nil {
log.Fatalln(err)
}
if r, err := Parse(input); err == nil {
return r
}
}
}
go run .
Is the weather good today? [y/n]
Is the weather good today? [y/n] x
Is the weather good today? [y/n] ?
Is the weather good today? [y/n] y
true
Bonus, intercept Ctrl+C with a graceful exit
A user can tap Ctrl+C to quit during the Parse loop, which causes an ugly signal interrupt exit.
go run .
Is the weather good today? [y/n] ^Csignal: interrupt
Create a new CtrlC function that will intercept the signal and gracefully exit with an emoji handwave. We must also call this function in YesNo before its input loop.
// CtrlC intercepts any Ctrl+C keyboard input and exits to the shell.
func CtrlC() {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
fmt.Fprintf(os.Stdout, " 👋\n")
os.Exit(0)
}()
}
func YesNo(s string) bool {
CtrlC()
for {
Is the weather good today? [y/n] ^C 👋
That’s it, nice work, and thank you for reading. The complete source code is on my GitHub repo.