Skip to content
This repository has been archived by the owner on Apr 19, 2024. It is now read-only.

Added an interface for all the public functions of the survey package #429

Draft
wants to merge 16 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,10 @@ survey.AskOne(

## Testing

There are two ways to test a program using survey:

### Using a simulated terminal

You can test your program's interactive prompts using [go-expect](https://github.com/Netflix/go-expect). The library
can be used to expect a match on stdout and respond on stdin. Since `os.Stdout` in a `go test` process is not a TTY,
if you are manipulating the cursor or using `survey`, you will need a way to interpret terminal / ANSI escape sequences
Expand All @@ -450,6 +454,70 @@ stdio to an in-memory [virtual terminal](https://github.com/hinshun/vt10x).

For some examples, you can see any of the tests in this repo.

### Using a mock (unstable API)

>**Warning:**
> The Mock API is currently still unstable and subject to change.
> Once it's done, it will be the recommended way to test survey.
> If you are unsure what to use right now, use a simulated terminal.

Instead of calling the survey functions directly, you can create a survey struct and call the functions from there.

```golang

survey := survey.Survey{}
SirRegion marked this conversation as resolved.
Show resolved Hide resolved

response := false
prompt := &survey.Confirm{
Message: "Do you like pie?",
}
survey.AskOne(prompt, &response)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now the big question is whether we should amend all other examples in the Readme to call Ask and AskOne on the Survey struct instead of on the package level? That would make everyone's code ultimately testable by default. Otherwise, the Readme will offer users usage examples of survey on the package level, but later the testing section will effectively tell them they need to rewrite their original code if they hope to mock Ask/AskOne calls. My hope for Survey is that it's testable by default without the need of changing the original implementation. @AlecAivazis Thoughts?

Copy link
Contributor Author

@SirRegion SirRegion Jun 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess updating the readme should not be to much work, as it's just one or two lines of code changed in each example.

I left it the same for this pull request, because I wasn't sure how much of a "breaking" change you would accept for the docs.

If we decide to update the docs, I would take on the work!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated the examples in the readme, but I did not touch the text for now.

Some parts ("Running the Prompts", "Testing") likely still need updating.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, good question. I think keeping the examples as short as possible is probably the best. I'd like to avoid the surveyor := survey.Surveyor{} at the start of every example so either we can reference an undefined surveyor in the examples or we can just use the package-level functions. Either way, we definitely want to add a section about these two different approaches in the "Running the Prompts" section so it shouldn't be too confusing if we do decide to update the tests to reference surveyor.Ask without explicitly showing its declaration.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think explicitly using surveyor.Ask in the examples is the way to go, because it makes the code testable by default (like @mislav noted).

Referencing an undefined surveyor is probably not even a bad move, because it might move users directly to passing it to functions instead of creation a new instance everytime they want to ask a question.

I would recommend updating the first example to include a function where the surveyor is passed as an argument to show how it is done "correctly".


```

If you create only one survey struct at the top level of your program and pass it to all functions, you can test those functions by replacing the struct with a mock provided by survey.

#### main

```golang
func main() {
surveyor := survey.Surveyor{}

AskForPie(surveyor)
}

func AskForPie(surveyor survey.SurveyInterface) bool {
response := false
prompt := &survey.Confirm{
Message: "Do you like pie?",
}
surveyor.AskOne(prompt, &response)

return response
}

```

#### Test

```golang
func TestAskForPie(t *testing.T) {

//create mock
mock := survey.SurveyorMock{}
//set the response the "user" should select
mock.setResponse(true)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If AskForPie is going to call survey.Ask() multiple times, how would we mock all those using SetResponse?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation does not allow to set multiple responses and would always use the last one that is set.
However, I feel like this could be acceptable behaviour, because it forces the developers to separate different ask calls into multiple functions that only ever do one thing (as they should 😉)

Alternatively, setResponse() could return the struct and store the responses in a slice. This would make this call joinable and allow multiple answers.

Copy link
Collaborator

@mislav mislav Jun 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel strongly that the mock solution should be flexible enough to mock all responses, no matter how many times Ask or AskOne has been called. I may be able to contribute this improvement to your mock implementation.

Example use case:

surveyer := survey.SurveyMock{}
surveyer.SetReponse(map[string]interface{}{
  "name": "foo",
  "message": "hello",
})

DoSomething(surveyer)

This test setup will work as long as DoSomething is calling Ask with two questions. However, let's say that the implementation of DoSomething needs to change because the prompt of question 2 needs to dynamically adjust to the response from question 1. The way to do that with survey is to switch from Ask to 2x AskOne. After the implementation changes in this way, the current test from the example will break. However, if the mock solution does not support stubbing several AskOnes, then the user will be forced to break up DoSomething into separate functions just to appease the SurveyMock implementation. I think this would cause frustration to the user.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a usecase i did not think of before.

How would you feel about something like this (inspired by jest):

mock := survey.Surveyor{}
mock.SetResponseOnce("first").SetResponseOnce("second").SetRespose("all others")

DoSomething(surveyor)

Now the mock would respond to the first question inside DoSomething with "first", two the second with "second" and to all other questions with "all others".

Now if DoSomething is using Ask, it would just fill the question struct with the answers "first", "second", "all others", "all others" if the prompt had four questions.

If DoSomething is using AskOne, it would respond to the first AskOne with "first", and to the second with "second", etc...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like your proposal; it looks more flexible already! One thing that I still think would be great if SurveyMock could do is verify that the prompt was shown to the user with the right text, since this text is the most important part of how the user interacts with Survey. We do it like this, which feels similar to what you've proposed, but allows for a greater level of precision.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we're going down this path we probably also want to provide methods on the mock for setting a specific field with a value (something like .setFieldResponse("name", "SirRegion"). I'm not sure there isn't a lot of value in coupling a test to the order in which the questions are asked but I think if someone wants to structure there test that way, we should support them

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SirRegion Yes, let's undo the overall README changes for now (to keep things as they were before) and have there just be a small mention & example of the mock functionality.

@AlecAivazis How would the SetFieldResponse() functionality allow for asserting the order in which questions were asked?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean to have both methods so a user could decide if they want to test the order of responses or just if a response was given for a field.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AlecAivazis Agreed! It's probably better to be strict with verification out-of-the-box (i.e. to verify the order of prompts by default) but give the developer a chance to opt out if needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mislav I reverted the commit that changed the readme for now and updated the Testing part to include a warning about the unstable mock API.


result := AskForPie(mock)

//check output of the function
if !result {
t.Fatal("AskForPie returned false, but it should have returned true")
}
}

```

## FAQ

### What kinds of IO are supported by `survey`?
Expand Down
16 changes: 16 additions & 0 deletions survey.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ import (
"github.com/AlecAivazis/survey/v2/terminal"
)

//SurveyorInterface includes all the functions a normal user uses when using survey
type SurveyorInterface interface {
AskOne(p Prompt, response interface{}, opts ...AskOpt) error
Ask(qs []*Question, response interface{}, opts ...AskOpt) error
}

type Surveyor struct{}

//the following two functions are just passthroughs to the actual functions, but they make sure that the surveyor struct is backwards compatible to calling the functions directly
func (surveyor Surveyor) AskOne(p Prompt, response interface{}, opts ...AskOpt) error {
return AskOne(p, response, opts...)
}
func (surveyor Surveyor) Ask(qs []*Question, response interface{}, opts ...AskOpt) error {
return Ask(qs, response, opts...)
}

// DefaultAskOptions is the default options on ask, using the OS stdio.
func defaultAskOptions() *AskOptions {
return &AskOptions{
Expand Down
41 changes: 41 additions & 0 deletions surveyorMock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package survey

import (
"github.com/AlecAivazis/survey/v2/core"
)

type SurveyorMock struct {
singleResponse interface{}
multipleResponses map[string]interface{}
}

func (mock *SurveyorMock) AskOne(p Prompt, response interface{}, opts ...AskOpt) error {
err := core.WriteAnswer(response, "", mock.singleResponse)
if err != nil {
// panicing is fine inside a mock
panic(err)
}
return nil
}

func (mock *SurveyorMock) Ask(qs []*Question, response interface{}, opts ...AskOpt) error {
for _, q := range qs {

err := core.WriteAnswer(response, q.Name, mock.multipleResponses[q.Name])
if err != nil {
// panicing is fine inside a mock
panic(err)
}

}

return nil
}

func (mock *SurveyorMock) SetResponse(response interface{}) {
if val, ok := response.(map[string]interface{}); ok {
mock.multipleResponses = val
} else {
mock.singleResponse = response
}
}
53 changes: 53 additions & 0 deletions surveyorMock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package survey

import (
"testing"
)

func TestMockAskOne(t *testing.T) {
t.Run("Setting Response", func(t *testing.T) {
mock := SurveyorMock{}
mock.SetResponse(true)

prompt := &Confirm{
Message: "test",
}

var response bool
mock.AskOne(prompt, &response)

if !response {
t.Fatalf("Response was false but should have been true!")
}
})

}

func TestMockAsk(t *testing.T) {
t.Run("Setting Response", func(t *testing.T) {
mock := SurveyorMock{}

test := make(map[string]interface{})
test["test"] = true

mock.SetResponse(test)

questions := []*Question{
{
Name: "test",
Prompt: &Confirm{
Message: "testing",
},
},
}

answer := struct {
Test bool
}{}
mock.Ask(questions, &answer)

if !answer.Test {
t.Fatalf("Response was false but should have been true!")
}
})
}