Programming Testing: Seams: Difference between revisions
(21 intermediate revisions by the same user not shown) | |||
Line 1: | Line 1: | ||
Seams are places where you can alter the behaviour of a program when it is under test.<br> | Seams are places where you can alter the behaviour of a program when it is under test.<br> | ||
When designing a program for testing, you should provide seams to allow it to be tested. | When designing a program for testing, you should provide seams to allow it to be tested. | ||
= PreProcessor Seams = | Seams allow you to: | ||
* substitute a file/method/object for another | |||
* override a method/object | |||
* type params, package vars with interfaces and pass/swap with a fake | |||
= Application Design = | |||
<blockquote> | |||
When writing your application, think about areas that: | |||
* have side effects (ex. filesystem operations) | |||
* need to expose testing-points (ex. logged messages) | |||
* handle behaviours (ex. renderers) | |||
Consider defining interfaces for these, and variations so they can be tested/isolated. | |||
</blockquote><!-- Seam Design --> | |||
= Seam Types = | |||
<blockquote> | |||
== PreProcessor Seams == | |||
<blockquote> | <blockquote> | ||
In C/C++ and other languages with macros,<br> | In C/C++ and other languages with macros,<br> | ||
you can use an <code>#ifdef</code> to inject or replace test methods. | you can use an <code>#ifdef</code> to inject or replace test methods. | ||
{{ expand | |||
| c example | |||
| | |||
{{ TODO | | |||
untested }} | |||
In the real file, we include <code>testmacros.h</code>,<br> | In the real file, we include <code>testmacros.h</code>,<br> | ||
Line 18: | Line 40: | ||
#include <stdio.h> | #include <stdio.h> | ||
#include "libusers.h" | #include "libusers.h" | ||
#include testmacros.h // <-- when testing, define 'create_db_user' locally | #include "testmacros.h" // <-- when testing, define 'create_db_user' locally | ||
int main(int argc, char *argv[]) { | int main(int argc, char *argv[]) { | ||
int id = 100; | int id = 100; | ||
create_db_user("foo@example.com", "foo"); | create_db_user("foo@example.com", "foo"); // <-- this will use our macro defined func | ||
} | } | ||
</syntaxhighlight> | </syntaxhighlight> | ||
Line 51: | Line 73: | ||
#endif | #endif | ||
</syntaxhighlight> | </syntaxhighlight> | ||
}} | |||
</blockquote><!-- PreProcessor Seams --> | </blockquote><!-- PreProcessor Seams --> | ||
= Linker/Import Seams = | == Linker/Import Seams == | ||
<blockquote> | <blockquote> | ||
You can alter the path code is sourced from to substitute in entirely new files. | You can alter the path code is sourced from to substitute in entirely new files. | ||
<syntaxhighlight lang=" | <syntaxhighlight lang="bash"> | ||
CLASSPATH=test/foo:${CLASSPATH} # java | CLASSPATH=test/foo:${CLASSPATH} # java | ||
GOPATH=test/foo:${GOPATH} # go | GOPATH=test/foo:${GOPATH} # go | ||
Line 65: | Line 88: | ||
</blockquote><!-- Linker Seams --> | </blockquote><!-- Linker Seams --> | ||
= Package Seams = | == Package/Module Seams == | ||
<blockquote> | <blockquote> | ||
Reassign a package variable for a single testrun. | |||
{{ expand | |||
| go example | |||
| | |||
This package defines OsCreate, a function that is used throghout the codebase. | |||
<syntaxhighlight lang="go"> | |||
// internal/fs/fs.go | |||
package fs | |||
import "os" | |||
var OsCreate = os.Create // exported function, we can override | |||
</syntaxhighlight> | |||
We call fs.OsCreate here | |||
<syntaxhighlight lang="go"> | |||
// foo.go | |||
package main | |||
import "foo.com/x/foo/internal/fs" | |||
func Foo() { | |||
_, err := fs.OsCreate("/var/tmp/foo.txt") | |||
if err != nil { | |||
panic(err) | |||
} | |||
} | |||
</syntaxhighlight> | |||
In our testfile, we swap OsCreate with one that returns an error | |||
<syntaxhighlight lang="go"> | |||
// foo_test.go | |||
package main | |||
import ( | |||
"errors" | |||
"foo.com/x/foo/internal/fs" | |||
) | |||
func TestFoo(t *testing.T) { | |||
var ExpectedError = errors.New("Expected") | |||
fs.OsCreate = func(path string) (*os.File, error) { | |||
return nil, ExpectedError | |||
} | |||
foo.Foo() // <-- when foo calls 'fs.OsCreate', it will return expected error | |||
} | |||
</syntaxhighlight> | |||
}} | |||
</blockquote><!-- Package Seams --> | </blockquote><!-- Package Seams --> | ||
= Object Seams = | == Object Seams == | ||
<blockquote> | <blockquote> | ||
=== Dependency Injection === | |||
<blockquote> | |||
Don't get information, and use it in one method.<br> | |||
Get info, then Pass information into method in separate steps. | |||
{{ expand | |||
| go example | |||
| | |||
Bad | |||
<syntaxhighlight lang="go"> | |||
func getUser() User { | |||
return User{First: "luke", Last: "skywalker"} | |||
} | |||
func FullName() string { | |||
user := getUser() | |||
return fmt.Sprint(user.First, user.Last) | |||
} | |||
func Main() { | |||
user = getUser() | |||
FullName(user.First, user.Last) | |||
} | |||
</syntaxhighlight> | |||
Good | |||
<syntaxhighlight lang="go"> | |||
func getUser() User { | |||
return User{First: "luke", Last: "skywalker"} | |||
} | |||
func FullName(first, last string) string { | |||
return fmt.Sprint(first, last) | |||
} | |||
func Main() { | |||
user = getUser() | |||
FullName(user.First, user.Last) | |||
} | |||
</syntaxhighlight> | |||
}} | |||
</blockquote><!-- Dependency Injection --> | |||
</blockquote><!-- Object Seams --> | </blockquote><!-- Object Seams --> | ||
= Interface Seams = | == Interface Seams == | ||
<blockquote> | |||
=== Inversion of Control === | |||
<blockquote> | |||
Params or Attributes typed as interfaces rather than concretions lets you sub in a fake.<br> | |||
You can also do this by passing lambdas. | |||
{{ expand | |||
| go example | |||
| | |||
Define an interface | |||
<syntaxhighlight lang="go"> | |||
// internal/interfaces/user.go | |||
type UserInterface interface { | |||
Rename(n string) error | |||
} | |||
</syntaxhighlight> | |||
Real code implements interface | |||
<syntaxhighlight lang="go"> | |||
// user.go | |||
type User struct {} | |||
func (this *User) Rename(n string) error { | |||
// ... | |||
} | |||
</syntaxhighlight> | |||
Fake also impelements interface | |||
<syntaxhighlight lang="go"> | |||
type FakeUser struct { | |||
RenameError error | |||
} | |||
func (this *FakeUser) Rename(n string) error { | |||
return this.RenameError | |||
} | |||
</syntaxhighlight> | |||
Foo accepts UserInterface, rather than concrete User. | |||
<syntaxhighlight lang="go"> | |||
// foo.go | |||
func Foo(user UserInterface) error { | |||
return user.Rename("vader") | |||
} | |||
</syntaxhighlight> | |||
Within test we'll pass the fake, which we can manipulate to return an error | |||
<syntaxhighlight lang="go"> | |||
// foo_test.go | |||
var ExpectedError = errors.New("Expected") | |||
func TestFoo(t *testing.T) { | |||
user := FakeUser{RenameError: ExpectedError} | |||
foo.Foo(user) | |||
} | |||
</syntaxhighlight> | |||
}} | |||
</blockquote><!-- Inversion of Control --> | |||
=== System Interface === | |||
<blockquote> | <blockquote> | ||
Rather than creating an interface for every object-type,<br> | |||
it might make sense to define an entire system (ex. all db calls).<br> | |||
Then you could combine this with a '''Package Seam'''. | |||
For example, [[golang afero]] defines an interface for all os/io operations.<br> | |||
One concretion performs real operations, another records them in-memory without changing filesystem. | |||
</blockquote><!-- System Interface --> | |||
</blockquote><!-- Interface Seams --> | </blockquote><!-- Interface Seams --> | ||
</blockquote><!-- Seam Types --> |
Latest revision as of 15:20, 23 July 2022
Seams are places where you can alter the behaviour of a program when it is under test.
When designing a program for testing, you should provide seams to allow it to be tested.
Seams allow you to:
- substitute a file/method/object for another
- override a method/object
- type params, package vars with interfaces and pass/swap with a fake
Application Design
When writing your application, think about areas that:
- have side effects (ex. filesystem operations)
- need to expose testing-points (ex. logged messages)
- handle behaviours (ex. renderers)
Consider defining interfaces for these, and variations so they can be tested/isolated.
Seam Types
PreProcessor Seams
In C/C++ and other languages with macros,
you can use an#ifdef
to inject or replace test methods.c example
TODO:
untested
In the real file, we include
testmacros.h
,
which in our tests will define functions locally.// main.c #include <stdio.h> #include "libusers.h" #include "testmacros.h" // <-- when testing, define 'create_db_user' locally int main(int argc, char *argv[]) { int id = 100; create_db_user("foo@example.com", "foo"); // <-- this will use our macro defined func }In our macro, we define a function that fakes creating a user in the database.
// testmacros.h #ifdef TESTING struct User { int id; char *email; char *name; } User users[5] = {}; #define create_db_user(email, name) \ { \ struct User user; \ user.id = 123; \ user.email = email; \ user.name = name; \ users[0] = user; \ } #endifLinker/Import Seams
You can alter the path code is sourced from to substitute in entirely new files.
CLASSPATH=test/foo:${CLASSPATH} # java GOPATH=test/foo:${GOPATH} # go PYTHONPATH=test/foo:${PYTHONPATH} # pythonPackage/Module Seams
Reassign a package variable for a single testrun.
go example
This package defines OsCreate, a function that is used throghout the codebase.// internal/fs/fs.go package fs import "os" var OsCreate = os.Create // exported function, we can overrideWe call fs.OsCreate here
// foo.go package main import "foo.com/x/foo/internal/fs" func Foo() { _, err := fs.OsCreate("/var/tmp/foo.txt") if err != nil { panic(err) } }In our testfile, we swap OsCreate with one that returns an error
// foo_test.go package main import ( "errors" "foo.com/x/foo/internal/fs" ) func TestFoo(t *testing.T) { var ExpectedError = errors.New("Expected") fs.OsCreate = func(path string) (*os.File, error) { return nil, ExpectedError } foo.Foo() // <-- when foo calls 'fs.OsCreate', it will return expected error }Object Seams
Dependency Injection
Don't get information, and use it in one method.
Get info, then Pass information into method in separate steps.go example
Badfunc getUser() User { return User{First: "luke", Last: "skywalker"} } func FullName() string { user := getUser() return fmt.Sprint(user.First, user.Last) } func Main() { user = getUser() FullName(user.First, user.Last) }Good
func getUser() User { return User{First: "luke", Last: "skywalker"} } func FullName(first, last string) string { return fmt.Sprint(first, last) } func Main() { user = getUser() FullName(user.First, user.Last) }Interface Seams
Inversion of Control
Params or Attributes typed as interfaces rather than concretions lets you sub in a fake.
You can also do this by passing lambdas.go example
Define an interface// internal/interfaces/user.go type UserInterface interface { Rename(n string) error }Real code implements interface
// user.go type User struct {} func (this *User) Rename(n string) error { // ... }Fake also impelements interface
type FakeUser struct { RenameError error } func (this *FakeUser) Rename(n string) error { return this.RenameError }Foo accepts UserInterface, rather than concrete User.
// foo.go func Foo(user UserInterface) error { return user.Rename("vader") }Within test we'll pass the fake, which we can manipulate to return an error
// foo_test.go var ExpectedError = errors.New("Expected") func TestFoo(t *testing.T) { user := FakeUser{RenameError: ExpectedError} foo.Foo(user) }
System Interface
Rather than creating an interface for every object-type,
it might make sense to define an entire system (ex. all db calls).
Then you could combine this with a Package Seam.For example, golang afero defines an interface for all os/io operations.
One concretion performs real operations, another records them in-memory without changing filesystem.