Often in distributed systems our products depend on third party applications. In this post I describe how we can mock these in order to improve coverage, testability and ease the setup of the test environment.
Imagine you have an application that calls an executable. You could for example create an XML document from a database and feed it to a third party application to get a PostScript file. We implement the Decorator Pattern and use a suitable class or function in our programming environment to
- Call the executable with or without certain arguments (input interface)
- Intercept the standard output and error to check our results and return to the main application (output interface)
On the unit test level we can mock the decorator itself. But if we have legacy code which isn't unit testable, or at the integration test level, it's useful to mock the third party executable itself. This will:
- speed up testing in case the third party application would take a while to finish,
- improve coverage: by taking control over the third party application we can provoke error states and need less test data setup to comply with the interface,
- make it easier to log which arguments the application is called with.
I will call a mocked executable FakeExe.
If our executable expects certain inputs we can control our FakeExe's behaviour based on these. We create an arguments encoder and a decoder. Here is a short example in Go of how we could control the exitcode for an executable expecting an XML file as input:
package fakeexe
//...
func EncodeArgument(exitCode int) string {
return strconv.Itoa(exitCode) + ext
}
type fakeExe struct {
ExitCode int
}
func (f *fakeExe) DecodeArgument(arg string) {
var err error
if len(arg) > len(ext) {
ecode := arg[:len(arg)-len(ext)]
f.ExitCode, err = strconv.Atoi(ecode)
} else {
err = errors.New("input not valid" + arg)
}
if err != nil {
f.handleError(err)
}
}
func (f *fakeExe) Run(arg string) {
f.DecodeArgument(arg)
}
func (f *fakeExe) handleError(error) {
//...
}
package main
import (
"fakeexe"
"os"
)
func main() {
f := new(fakeexe.fakeExe)
f.Run(os.Args[0])
os.Exit(f.ExitCode)
}
The Encoder can be used in test setup code in order to generate the correct input args for the FakeExe. It can be extended with the following useful behaviour:
- Write a log: this way we can control that the Decorator calls the executable as expected and we can check the FakeExe in case the possible actions are more complicated than the easy example given above.
- Use input files from paths, for example if there is an input files directory which will be used as working directory. This can also be used to configure the FakeExe in case of several similar expected behaviours where we might not want to implement a seperate FakeExe for each executable we mock.
At some point I faced the problem that the working directories of the executable wasn't known beforehand, it was created when the task using the Decorator was run. Furthermore, many instances of the same executable were called in a workflow. This created two problems:
- There was no use in writing the log per FakeExe instance: I needed a log of all instances together.
- Configuring the FakeExe by a configuration file beside it was undoable because it wouldn't have been copied to the target working directory.
I solved the problem by implementing a logger and a configuration service which in Go reduced to just some lines of code, see http://golang.org/pkg/net/. The easiest implementation might look like this:
package configuring
import (
"fmt"
"io/ioutil"
"net"
)
var ConfigFilename = "Config.txt"
//...
type server struct {
getListener func(protocol, port string) (net.Listener, error)
ln net.Listener
protocol string
port string
}
func (srv *server) Start() {
var err error
srv.ln, err = srv.getListener(srv.protocol, ":"+srv.port)
if err != nil {
panic(err)
}
for {
conn, err := srv.ln.Accept()
if err != nil {
fmt.Println(err)
continue
}
go srv.sendConfig(conn)
}
return
}
func (srv *server) sendConfig(conn net.Conn) {
bytes, err := ioutil.ReadFile(ConfigFilename)
if err != nil {
panic(err)
}
if len(bytes) > MAX_MESSAGE_LENGTH {
panic("Config message too long.")
}
_, err = conn.Write(bytes)
if err != nil {
panic(err)
}
return
}
func (srv *server) Stop() {
if srv.ln != nil {
srv.ln.Close()
}
}
package logging
import (
"net"
"fmt"
)
//...
type server struct{
getListener func(protocol, port string) (net.Listener, error)
ln net.Listener
protocol string
port string
msgs []string
}
func (srv *server)Msgs() (msgs []string){
msgs = srv.msgs
return
}
func (srv *server)Start() (err error){
srv.ln, err = srv.getListener(srv.protocol, ":" + srv.port)
if(err != nil){
panic(err)
}
for {
conn, err := srv.ln.Accept()
if (err != nil){
fmt.Println(err)
continue
}
go srv.appendMessage(conn)
}
return
}
func (srv *server)appendMessage(conn net.Conn){
defer conn.Close()
buf := make([]byte, MAX_MESSAGE_LENGTH)
msg_length, err := conn.Read(buf)
var msg string
if(err != nil){
msg = err.Error()
}else{
msg = string(buf[:msg_length])
}
srv.msgs = append(srv.msgs, msg)
}
func (srv *server)Stop(){
if(srv.ln != nil){
srv.ln.Close()
}
}
From this the next step could be the implementation of a little DSL for our testing extending the FakeExe with an interpreter like for example in Bob's Blog - Writing a Lisp Interpreter in Go. Then, instead of sending concrete implementation specific configuration values with the configuration service we just send a script:
package fakeexe
import (
"lisp" //https://github.com/bobappleyard/golisp
"io"
"strings"
)
//...
func (f *fakeExe)Run(script string){
i := lisp.New()
i.Repl(strings.NewReader(script), io.Stdout)
//...
}
*Examples are written in Go.