Avoid Testing Implementation Details, Test Behaviours

Hi all,

I have a scenario where I would like to test the behaviour of a function rather than the implementation. For example, I have unit tested foo and bar, but I now want to ensure they are called correctly from baz.

I come from Java/Groovy where we had a test framework called Spock to do this nicely. Spock allowed you to write interaction based tests as follows:

def "should send messages to all subscribers"() {
    when:
    publisher.send("hello")

    then:
    1 * subscriber.receive("hello")
    1 * subscriber2.receive("hello")
}

All this test does is ensures that when I call publisher.send() with the parameter hello, then the methods subscriber.receive() and subscriber2.receive() are both called exactly once, denoted by 1 *, with the parameter hello.

Is there a way of doing this in Go?

You can mock out the subscribers, have them increment a counter when called, and compare that after the send. For this purpose it makes things easier if the publisher takes the subscribers in the form of interfaces.

Apologies, I steered you in the wrong direction with that example. In my scenario, the functions are part of the same package.

Maybe a concrete example would be better:

// parseClipping
// takes a single clipping string and populates a Clipping object
func parseClipping(input string, c *clipping.Clipping) {

	// create a scanner object to read the clipping
	scanner := bufio.NewScanner(strings.NewReader(input))
	scanner.Split(bufio.ScanLines)
	lineNumber := 1

	// iterate over the lines, "\n" is the delimiter
	for scanner.Scan() {
		log.Printf("Line %d, content: %s", lineNumber, input)
		// from the line number, determine what parsing is required
		switch lineNumber {
		case 1:
			parseLine1(scanner.Text(), c)
		case 4:
			parseContent(scanner.Text(), c)
		default:
			return
		}
		lineNumber++
	}
	if err := scanner.Err(); err != nil {
		fmt.Fprintln(os.Stderr, "reading standard input:", err)
	}
}

// parseLine1
// the first line in a Kindle clipping chunk includes the title and author
func parseLine1(s string, c *clipping.Clipping) {
	c.Book = parseTitle(s)
	c.Author = parseAuthor(s)
}

// parseContent
// the content is always the
func parseContent(input string, c *clipping.Clipping) {
	log.Printf("Content Input: %s", input)
	c.Content = input
}

So, in this test I want to check that parseClipping() successfully calls parseLine1() and parseContent() only once.

You can declare them as package level variables and override them in testing.

var parseLine1 = realParseLine1

func realParseLine1(s string, c *clipping.Clipping) {
	c.Book = parseTitle(s)
	c.Author = parseAuthor(s)
}

// other functions use parseLine1 as usual

// ------ in testing

func TestFoo(t* testing.T) {
    calledParseLine1 := 0
    parseLine1 = func (s string, c *clipping.Clipping) {
        calledParseLine1++
        realParseLine1(s, c)
    }
   // do the test
   // check the counter
}
1 Like

If I’m understanding correctly, we are making parseLine1 an interface? In the main code we use realParseLine1 as a concrete implementation, but in the test we put a wrapper around realParseLine1 which includes a counter.

As I’m not too bothered about the implementation of realParseLine1, because it has already been tested. I guess I don’t even need to call it? Simply, increase the counter and assert parseLine1 has only been called once (test the behaviour).

Sort of, but it’s a function variable not an interface. However all of this feels like testing the implementation details of parseClipping, but maybe your real life case is different.

This kind of on the fly function replacement I would typically expect where you need to insert errors, i.e.test behavior when a file read returns an error, or to mock out something that returns an otherwise tricky to fake value - the time of day for example.

Thanks, your contribution has helped me a lot. :smile:

For other people who land here, further detail to the response from @calmh can be found below:
http://npf.io/2014/04/mocking-functions-in-go/

1 Like

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.