1

I am new to golang and I am using an interactive prompt called promptui (https://github.com/manifoldco/promptui) in a project of mine. I have written several unit tests for this project already but I am struggling with how I would unit test this particular package that requires an input.

For example, How would I go about testing the following lines of code (encapsulated in a function):

func setEmail() string {
  prompt := promptui.Prompt{Label: "Input your Email",
     Validate: emailValidations,
  }

  email, err := prompt.Run()
  if err != nil {
    color.red("failed getting email")
    os.exit(3)
  }
  return email
}

I think I need to somehow mock stdin but can't figure out the best way to do that within a test.

Steve
  • 149
  • 1
  • 11
  • 1
    Why would you unit test an external package? It should have its own tests already. – Adrian Nov 14 '18 at 18:32
  • 1
    Well I want to test my function that uses that package. – Steve Nov 14 '18 at 18:34
  • Your function that uses it doesn't do anything itself, unit testing it delivers no value. – Adrian Nov 14 '18 at 18:48
  • It returns the email string. The function is used a few times throughout. I guess I could just reuse the same promptui code block throughout but thought that would go against DRY best practices. – Steve Nov 14 '18 at 18:58
  • I agree that there is a value in testing code that calls `promptui`. Tests create a `safety net` that reduce regression errors. You never know how you have to refactor/extend that code in the future. – Dmitry Harnitski Nov 14 '18 at 20:13

3 Answers3

4

You should not try to test promptui as it is expected to be tested by its author.

What you can test:

  1. You send correct parameters when you create promptui.Prompt
  2. You use that promptui.Prompt in your code
  3. You properly handle promptui.Prompt results

As you can see, all these tests does not verify if promptui.Prompt works correctly inside.

Tests #2 and #3 could be combined. You need to run you code against mock and if you got correct result, you can believe that both #2 and #3 are correct.

Create mock:

type Runner interface {
    Run() (int, string, error)
}

type promptMock struct {
    // t is not required for this test, but it is would be helpful to assert input parameters if we have it in Run()
    t *testing.T
}

func (p promptMock) Run() (int, string, error) {
    // return expected result
    return 1, "", nil
}

You will need separate mock for testing error flow.

Update your code to inject mock:

func setEmail(runner Runner) string {
    email, err := runner.Run()
    if err != nil {
      color.red("failed getting email")
      os.exit(3)
    }
    return email
}

Now it is testable.

Create function that creates prompt:

func getRunner() promptui.Prompt {
  return promptui.Prompt{Label: "Input your Email",
     Validate: emailValidations,
  }
} 

Write simple assert test to verify that we create correct structure.

The only not tested line will be setEmail(getRunner()) but it is trivial and can be covered by other types of tests.

Dmitry Harnitski
  • 5,838
  • 1
  • 28
  • 43
2

For whatever reason, they don't export their stdin interface (https://github.com/manifoldco/promptui/blob/master/prompt.go#L49), so you can't mock it out, but you can directly mock os.Stdin and prefill it with whatever you need for testing. Though I agree with @Adrian, it has its own tests, so this shouldn't be necessary.

Extracted and refactored/simplified from source: Fill os.Stdin for function that reads from it

Refactored this way, it can be used for any function that reads from os.Stdin and expects a specific string.

Playground link: https://play.golang.org/p/rjgcGIaftBK

func TestSetEmail(t *testing.T) {
    if err := TestExpectedStdinFunc("email@test.com", setEmail); err != nil {
        t.Error(err)
        return
    }
    fmt.Println("success")
}

func TestExpectedStdinFunc(expected string, f func() string) error {
    content := []byte(expected)
    tmpfile, err := ioutil.TempFile("", "example")
    if err != nil {
        return err
    }

    defer os.Remove(tmpfile.Name()) // clean up

    if _, err := tmpfile.Write(content); err != nil {
        return err
    }

    if _, err := tmpfile.Seek(0, 0); err != nil {
        return err
    }

    oldStdin := os.Stdin
    defer func() { os.Stdin = oldStdin }() // Restore original Stdin

    os.Stdin = tmpfile
    actual := f()
    if actual != expected {
        return errors.New(fmt.Sprintf("test failed, exptected: %s actual: %s", expected, actual))
    }

    if err := tmpfile.Close(); err != nil {
        return err
    }
    return nil
}
RayfenWindspear
  • 6,116
  • 1
  • 30
  • 42
2

promptui now has the Stdin property.

There is a fiddle here: https://play.golang.org/p/-mSgjY2kAw-

Here is our function that we will be testing:

func mock(p promptui.Prompt) string {
    p.Label = "[Y/N]"
    user_input, err := p.Run()
    if err != nil {
        fmt.Printf("Prompt failed %v\n", err)
    }

    return user_input
}

We need to create p, which will be an instance of promptui.Prompt and have a custom Stdin.

I got some help here - https://groups.google.com/g/golang-nuts/c/J-Y4LtdGNSw?pli=1 - in how to make a custom Stdin value, which simply has to conform to io.ReadCloser.

type ClosingBuffer struct {
    *bytes.Buffer
}

func (cb ClosingBuffer) Close() error {
    return nil
}

And then you use that as Stdin in the reader:

func TestMock(t *testing.T) {

    reader := ClosingBuffer{
        bytes.NewBufferString("N\n"),
    }

    p := promptui.Prompt{
        Stdin: reader,
    }

    response := mock(p)

    if !strings.EqualFold(response, "N") {
        t.Errorf("nope!")
    }
    //t.Errorf(response)
}

edit: The above doesn't work for multiple prompts within the same function, as discussed here with a solution: https://github.com/manifoldco/promptui/issues/63 - "promptui internally uses a buffer of 4096 bytes. This means that you must pad your buffer or promptui will raise EOF."

I took this pad() function from that exchange - https://github.com/sandokandias/capiroto/blob/master/cmd/capiroto/main.go:

func pad(siz int, buf *bytes.Buffer) {
    pu := make([]byte, 4096-siz)
    for i := 0; i < 4096-siz; i++ {
        pu[i] = 97
    }
    buf.Write(pu)
}

Then the test - - this solution uses ioutil.NopCloser rather than creating a new struct:

func TestMock(t *testing.T) {

    i1 := "N\n"
    i2 := "Y\n"

    b := bytes.NewBuffer([]byte(i1))
    pad(len(i1), b)
    reader := ioutil.NopCloser(
        b,
    )
    b.WriteString(i2)
    pad(len(i2), b)

    p := promptui.Prompt{
        Stdin: reader,
    }

    response := mock(p)

    if !strings.EqualFold(response, "NY") {
        t.Errorf("nope!")
        t.Errorf(response)
    }
}

and the function we are testing:

func mock(p promptui.Prompt) string {
    p.Label = "[Y/N]"
    user_input, err := p.Run()
    if err != nil {
        fmt.Printf("Prompt failed %v\n", err)
    }
    user_input2, err := p.Run()

    return user_input + user_input2
}

The fiddle for multiple prompts is here: https://play.golang.org/p/ElPysYq8aM1

iateadonut
  • 1,951
  • 21
  • 32