diff --git a/cmd/apply.go b/cmd/apply.go
index e5ad691ab4b9580753a09aa98ca0d6c5fae04207..e8ad6e09734a83e45d67ada13f173adef185b299 100644
--- a/cmd/apply.go
+++ b/cmd/apply.go
@@ -116,7 +116,13 @@ var applyCmd = &cobra.Command{
 			return err
 		}
 
-		objs, err := expandEnvCmdObjs(cmd, env, componentNames, wd)
+		te := newCmdObjExpander(cmdObjExpanderConfig{
+			cmd:        cmd,
+			env:        env,
+			components: componentNames,
+			cwd:        wd,
+		})
+		objs, err := te.Expand()
 		if err != nil {
 			return err
 		}
diff --git a/cmd/delete.go b/cmd/delete.go
index a94827298a4429886a3db3758e5cbcb945d3ee08..9e39e57024da913e3025e5810666c60e93c58293 100644
--- a/cmd/delete.go
+++ b/cmd/delete.go
@@ -78,7 +78,13 @@ var deleteCmd = &cobra.Command{
 			return err
 		}
 
-		objs, err := expandEnvCmdObjs(cmd, env, componentNames, wd)
+		te := newCmdObjExpander(cmdObjExpanderConfig{
+			cmd:        cmd,
+			env:        env,
+			components: componentNames,
+			cwd:        wd,
+		})
+		objs, err := te.Expand()
 		if err != nil {
 			return err
 		}
diff --git a/cmd/diff.go b/cmd/diff.go
index 93dcec1ddecc9998ecbf6e4cdacb7e0a98f48058..2893f164ca07141a5d1b50810a7f2f8be63811c6 100644
--- a/cmd/diff.go
+++ b/cmd/diff.go
@@ -23,6 +23,7 @@ import (
 	"k8s.io/client-go/discovery"
 	"k8s.io/client-go/dynamic"
 
+	"github.com/spf13/afero"
 	"github.com/spf13/cobra"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/client-go/tools/clientcmd"
@@ -82,7 +83,7 @@ var diffCmd = &cobra.Command{
 			return err
 		}
 
-		c, err := initDiffCmd(cmd, wd, env1, env2, componentNames, diffStrategy)
+		c, err := initDiffCmd(appFs, cmd, wd, env1, env2, componentNames, diffStrategy)
 		if err != nil {
 			return err
 		}
@@ -140,14 +141,14 @@ ks diff dev -c redis
 `,
 }
 
-func initDiffCmd(cmd *cobra.Command, wd metadata.AbsPath, envFq1, envFq2 *string, files []string, diffStrategy string) (kubecfg.DiffCmd, error) {
+func initDiffCmd(fs afero.Fs, cmd *cobra.Command, wd metadata.AbsPath, envFq1, envFq2 *string, files []string, diffStrategy string) (kubecfg.DiffCmd, error) {
 	const (
 		remote = "remote"
 		local  = "local"
 	)
 
 	if envFq2 == nil {
-		return initDiffSingleEnv(*envFq1, diffStrategy, files, cmd, wd)
+		return initDiffSingleEnv(fs, *envFq1, diffStrategy, files, cmd, wd)
 	}
 
 	// expect envs to be of the format local:myenv or remote:myenv
@@ -168,11 +169,11 @@ func initDiffCmd(cmd *cobra.Command, wd metadata.AbsPath, envFq1, envFq2 *string
 	}
 
 	if env1[0] == local && env2[0] == local {
-		return initDiffLocalCmd(env1[1], env2[1], diffStrategy, cmd, manager)
+		return initDiffLocalCmd(fs, env1[1], env2[1], diffStrategy, cmd, manager)
 	}
 
 	if env1[0] == remote && env2[0] == remote {
-		return initDiffRemotesCmd(env1[1], env2[1], diffStrategy, cmd, manager)
+		return initDiffRemotesCmd(fs, env1[1], env2[1], diffStrategy, cmd, manager)
 	}
 
 	localEnv := env1[1]
@@ -181,11 +182,11 @@ func initDiffCmd(cmd *cobra.Command, wd metadata.AbsPath, envFq1, envFq2 *string
 		localEnv = env2[1]
 		remoteEnv = env1[1]
 	}
-	return initDiffRemoteCmd(localEnv, remoteEnv, diffStrategy, cmd, manager)
+	return initDiffRemoteCmd(fs, localEnv, remoteEnv, diffStrategy, cmd, manager)
 }
 
 // initDiffSingleEnv sets up configurations for diffing using one environment
-func initDiffSingleEnv(env, diffStrategy string, files []string, cmd *cobra.Command, wd metadata.AbsPath) (kubecfg.DiffCmd, error) {
+func initDiffSingleEnv(fs afero.Fs, env, diffStrategy string, files []string, cmd *cobra.Command, wd metadata.AbsPath) (kubecfg.DiffCmd, error) {
 	c := kubecfg.DiffRemoteCmd{}
 	c.DiffStrategy = diffStrategy
 	c.Client = &kubecfg.Client{}
@@ -195,7 +196,13 @@ func initDiffSingleEnv(env, diffStrategy string, files []string, cmd *cobra.Comm
 		return nil, fmt.Errorf("single <env> argument with prefix 'local:' or 'remote:' not allowed")
 	}
 
-	c.Client.APIObjects, err = expandEnvCmdObjs(cmd, env, files, wd)
+	te := newCmdObjExpander(cmdObjExpanderConfig{
+		cmd:        cmd,
+		env:        env,
+		components: files,
+		cwd:        wd,
+	})
+	c.Client.APIObjects, err = te.Expand()
 	if err != nil {
 		return nil, err
 	}
@@ -214,21 +221,21 @@ func initDiffSingleEnv(env, diffStrategy string, files []string, cmd *cobra.Comm
 }
 
 // initDiffLocalCmd sets up configurations for diffing between two sets of expanded Kubernetes objects locally
-func initDiffLocalCmd(env1, env2, diffStrategy string, cmd *cobra.Command, m metadata.Manager) (kubecfg.DiffCmd, error) {
+func initDiffLocalCmd(fs afero.Fs, env1, env2, diffStrategy string, cmd *cobra.Command, m metadata.Manager) (kubecfg.DiffCmd, error) {
 	c := kubecfg.DiffLocalCmd{}
 	c.DiffStrategy = diffStrategy
 	var err error
 
 	c.Env1 = &kubecfg.LocalEnv{}
 	c.Env1.Name = env1
-	c.Env1.APIObjects, err = expandEnvObjs(cmd, c.Env1.Name, m)
+	c.Env1.APIObjects, err = expandEnvObjs(fs, cmd, c.Env1.Name, m)
 	if err != nil {
 		return nil, err
 	}
 
 	c.Env2 = &kubecfg.LocalEnv{}
 	c.Env2.Name = env2
-	c.Env2.APIObjects, err = expandEnvObjs(cmd, c.Env2.Name, m)
+	c.Env2.APIObjects, err = expandEnvObjs(fs, cmd, c.Env2.Name, m)
 	if err != nil {
 		return nil, err
 	}
@@ -237,7 +244,7 @@ func initDiffLocalCmd(env1, env2, diffStrategy string, cmd *cobra.Command, m met
 }
 
 // initDiffRemotesCmd sets up configurations for diffing between objects on two remote clusters
-func initDiffRemotesCmd(env1, env2, diffStrategy string, cmd *cobra.Command, m metadata.Manager) (kubecfg.DiffCmd, error) {
+func initDiffRemotesCmd(fs afero.Fs, env1, env2, diffStrategy string, cmd *cobra.Command, m metadata.Manager) (kubecfg.DiffCmd, error) {
 	c := kubecfg.DiffRemotesCmd{}
 	c.DiffStrategy = diffStrategy
 
@@ -248,11 +255,11 @@ func initDiffRemotesCmd(env1, env2, diffStrategy string, cmd *cobra.Command, m m
 	c.ClientB.Name = env2
 
 	var err error
-	c.ClientA.APIObjects, err = expandEnvObjs(cmd, c.ClientA.Name, m)
+	c.ClientA.APIObjects, err = expandEnvObjs(fs, cmd, c.ClientA.Name, m)
 	if err != nil {
 		return nil, err
 	}
-	c.ClientB.APIObjects, err = expandEnvObjs(cmd, c.ClientB.Name, m)
+	c.ClientB.APIObjects, err = expandEnvObjs(fs, cmd, c.ClientB.Name, m)
 	if err != nil {
 		return nil, err
 	}
@@ -270,13 +277,13 @@ func initDiffRemotesCmd(env1, env2, diffStrategy string, cmd *cobra.Command, m m
 }
 
 // initDiffRemoteCmd sets up configurations for diffing between local objects and objects on a remote cluster
-func initDiffRemoteCmd(localEnv, remoteEnv, diffStrategy string, cmd *cobra.Command, m metadata.Manager) (kubecfg.DiffCmd, error) {
+func initDiffRemoteCmd(fs afero.Fs, localEnv, remoteEnv, diffStrategy string, cmd *cobra.Command, m metadata.Manager) (kubecfg.DiffCmd, error) {
 	c := kubecfg.DiffRemoteCmd{}
 	c.DiffStrategy = diffStrategy
 	c.Client = &kubecfg.Client{}
 
 	var err error
-	c.Client.APIObjects, err = expandEnvObjs(cmd, localEnv, m)
+	c.Client.APIObjects, err = expandEnvObjs(fs, cmd, localEnv, m)
 	if err != nil {
 		return nil, err
 	}
@@ -309,8 +316,8 @@ func setupClientConfig(env *string, cmd *cobra.Command) (dynamic.ClientPool, dis
 }
 
 // expandEnvObjs finds and expands templates for an environment
-func expandEnvObjs(cmd *cobra.Command, env string, manager metadata.Manager) ([]*unstructured.Unstructured, error) {
-	expander, err := newExpander(cmd)
+func expandEnvObjs(fs afero.Fs, cmd *cobra.Command, env string, manager metadata.Manager) ([]*unstructured.Unstructured, error) {
+	expander, err := newExpander(fs, cmd)
 	if err != nil {
 		return nil, err
 	}
diff --git a/cmd/root.go b/cmd/root.go
index 947b07e4bc139fad3034a55541828828e27eab64..793dde9b50f9420a43f92891e89b137cd415eedb 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -29,7 +29,9 @@ import (
 
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 
+	"github.com/pkg/errors"
 	log "github.com/sirupsen/logrus"
+	"github.com/spf13/afero"
 	"github.com/spf13/cobra"
 	"golang.org/x/crypto/ssh/terminal"
 	"k8s.io/client-go/discovery"
@@ -61,11 +63,16 @@ const (
 	flagComponentShort = "c"
 )
 
-var clientConfig clientcmd.ClientConfig
-var overrides clientcmd.ConfigOverrides
-var loadingRules clientcmd.ClientConfigLoadingRules
+var (
+	clientConfig clientcmd.ClientConfig
+	overrides    clientcmd.ConfigOverrides
+	loadingRules clientcmd.ClientConfigLoadingRules
+	appFs        afero.Fs
+)
 
 func init() {
+	appFs = afero.NewOsFs()
+
 	RootCmd.PersistentFlags().CountP(flagVerbose, "v", "Increase verbosity. May be given multiple times.")
 
 	// The "usual" clientcmd/kubectl flags
@@ -222,9 +229,9 @@ func (f *logFormatter) Format(e *log.Entry) ([]byte, error) {
 	return buf.Bytes(), nil
 }
 
-func newExpander(cmd *cobra.Command) (*template.Expander, error) {
+func newExpander(fs afero.Fs, cmd *cobra.Command) (*template.Expander, error) {
 	flags := cmd.Flags()
-	spec := template.Expander{}
+	spec := template.NewExpander(fs)
 	var err error
 
 	spec.EnvJPath = filepath.SplitList(os.Getenv("KUBECFG_JPATH"))
@@ -379,39 +386,64 @@ func overrideCluster(envName string, clientConfig clientcmd.ClientConfig, overri
 	return fmt.Errorf("Attempting to deploy to environment '%s' at '%s', but cannot locate a server at that address", envName, env.Server)
 }
 
-// expandEnvCmdObjs finds and expands templates for the family of commands of
+type cmdObjExpanderConfig struct {
+	fs         afero.Fs
+	cmd        *cobra.Command
+	env        string
+	components []string
+	cwd        metadata.AbsPath
+}
+
+// cmdObjExpander finds and expands templates for the family of commands of
 // the form `[<env>|-f <file-name>]`, e.g., `apply` and `delete`. That is, if
 // the user passes a list of files, we will expand all templates in those files,
 // while if a user passes an environment name, we will expand all component
 // files using that environment.
-func expandEnvCmdObjs(cmd *cobra.Command, env string, components []string, cwd metadata.AbsPath) ([]*unstructured.Unstructured, error) {
-	expander, err := newExpander(cmd)
+type cmdObjExpander struct {
+	config             cmdObjExpanderConfig
+	templateExpanderFn func(afero.Fs, *cobra.Command) (*template.Expander, error)
+}
+
+func newCmdObjExpander(c cmdObjExpanderConfig) *cmdObjExpander {
+	if c.fs == nil {
+		c.fs = appFs
+	}
+
+	return &cmdObjExpander{
+		config:             c,
+		templateExpanderFn: newExpander,
+	}
+}
+
+// Expands expands the templates.
+func (te *cmdObjExpander) Expand() ([]*unstructured.Unstructured, error) {
+	expander, err := te.templateExpanderFn(te.config.fs, te.config.cmd)
 	if err != nil {
-		return nil, err
+		return nil, errors.Wrap(err, "template expander")
 	}
 
 	//
 	// Set up the template expander to be able to expand the ksonnet application.
 	//
 
-	manager, err := metadata.Find(cwd)
+	manager, err := metadata.Find(te.config.cwd)
 	if err != nil {
-		return nil, err
+		return nil, errors.Wrap(err, "find metadata")
 	}
 
 	libPath, vendorPath := manager.LibPaths()
-	metadataPath, mainPath, paramsPath, specPath := manager.EnvPaths(env)
+	metadataPath, mainPath, paramsPath, specPath := manager.EnvPaths(te.config.env)
 
 	expander.FlagJpath = append([]string{string(libPath), string(vendorPath), string(metadataPath)}, expander.FlagJpath...)
 
 	componentPaths, err := manager.ComponentPaths()
 	if err != nil {
-		return nil, err
+		return nil, errors.Wrap(err, "component paths")
 	}
 
-	baseObj, err := constructBaseObj(componentPaths, components)
+	baseObj, err := constructBaseObj(componentPaths, te.config.components)
 	if err != nil {
-		return nil, err
+		return nil, errors.Wrap(err, "construct base object")
 	}
 
 	//
diff --git a/cmd/show.go b/cmd/show.go
index 02ba75970fa366575c5f2f0493f5d6fecffe4186..42196e59af845ffa95c20615ac7643778be10113 100644
--- a/cmd/show.go
+++ b/cmd/show.go
@@ -99,7 +99,13 @@ ks show dev -c redis -c nginx-server
 		}
 		wd := metadata.AbsPath(cwd)
 
-		objs, err := expandEnvCmdObjs(cmd, env, componentNames, wd)
+		te := newCmdObjExpander(cmdObjExpanderConfig{
+			cmd:        cmd,
+			env:        env,
+			components: componentNames,
+			cwd:        wd,
+		})
+		objs, err := te.Expand()
 		if err != nil {
 			return err
 		}
diff --git a/cmd/validate.go b/cmd/validate.go
index 3e629662928121ded981cb5589ac9c8691674b81..64afd822ba3238ac4785575db2b32ff6d9ad1619 100644
--- a/cmd/validate.go
+++ b/cmd/validate.go
@@ -66,7 +66,13 @@ var validateCmd = &cobra.Command{
 			return err
 		}
 
-		objs, err := expandEnvCmdObjs(cmd, env, componentNames, wd)
+		te := newCmdObjExpander(cmdObjExpanderConfig{
+			cmd:        cmd,
+			env:        env,
+			components: componentNames,
+			cwd:        wd,
+		})
+		objs, err := te.Expand()
 		if err != nil {
 			return err
 		}
diff --git a/template/expander.go b/template/expander.go
index 93ea47b666b7683e1422df61da6293fbd30c1a44..1efe6d93784417dbd012d8990c64d2149db01c45 100644
--- a/template/expander.go
+++ b/template/expander.go
@@ -2,7 +2,6 @@ package template
 
 import (
 	"fmt"
-	"io/ioutil"
 	"os"
 	"strings"
 
@@ -11,6 +10,7 @@ import (
 	jsonnet "github.com/google/go-jsonnet"
 	"github.com/ksonnet/ksonnet/utils"
 	log "github.com/sirupsen/logrus"
+	"github.com/spf13/afero"
 )
 
 type Expander struct {
@@ -24,8 +24,21 @@ type Expander struct {
 
 	Resolver   string
 	FailAction string
+
+	fs afero.Fs
+}
+
+func NewExpander(fs afero.Fs) Expander {
+	if fs == nil {
+		fs = afero.NewOsFs()
+	}
+
+	return Expander{
+		fs: fs,
+	}
 }
 
+// Expand expands paths to a slice of v1 Unstructured objects.
 func (spec *Expander) Expand(paths []string) ([]*unstructured.Unstructured, error) {
 	vm, err := spec.jsonnetVM()
 	if err != nil {
@@ -34,7 +47,7 @@ func (spec *Expander) Expand(paths []string) ([]*unstructured.Unstructured, erro
 
 	res := []*unstructured.Unstructured{}
 	for _, path := range paths {
-		objs, err := utils.Read(vm, path)
+		objs, err := utils.Read(spec.fs, vm, path)
 		if err != nil {
 			return nil, fmt.Errorf("Error reading %s: %v", path, err)
 		}
@@ -83,7 +96,7 @@ func (spec *Expander) jsonnetVM() (*jsonnet.VM, error) {
 		if len(kv) != 2 {
 			return nil, fmt.Errorf("Failed to parse ext var files: missing '=' in %s", extvar)
 		}
-		v, err := ioutil.ReadFile(kv[1])
+		v, err := afero.ReadFile(spec.fs, kv[1])
 		if err != nil {
 			return nil, err
 		}
@@ -110,7 +123,7 @@ func (spec *Expander) jsonnetVM() (*jsonnet.VM, error) {
 		if len(kv) != 2 {
 			return nil, fmt.Errorf("Failed to parse tla var files: missing '=' in %s", tlavar)
 		}
-		v, err := ioutil.ReadFile(kv[1])
+		v, err := afero.ReadFile(spec.fs, kv[1])
 		if err != nil {
 			return nil, err
 		}
diff --git a/utils/acquire.go b/utils/acquire.go
index 7b662ad7ebf2623c6068c0cace8b84413bd61ce5..004742ad8a8a628f483a1a0de520955bd18e2302 100644
--- a/utils/acquire.go
+++ b/utils/acquire.go
@@ -26,6 +26,7 @@ import (
 
 	jsonnet "github.com/google/go-jsonnet"
 	log "github.com/sirupsen/logrus"
+	"github.com/spf13/afero"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/util/yaml"
@@ -34,7 +35,7 @@ import (
 // Read fetches and decodes K8s objects by path.
 // TODO: Replace this with something supporting more sophisticated
 // content negotiation.
-func Read(vm *jsonnet.VM, path string) ([]runtime.Object, error) {
+func Read(fs afero.Fs, vm *jsonnet.VM, path string) ([]runtime.Object, error) {
 	ext := filepath.Ext(path)
 	if ext == ".json" {
 		f, err := os.Open(path)
@@ -51,7 +52,7 @@ func Read(vm *jsonnet.VM, path string) ([]runtime.Object, error) {
 		defer f.Close()
 		return yamlReader(f)
 	} else if ext == ".jsonnet" {
-		return jsonnetReader(vm, path)
+		return jsonnetReader(fs, vm, path)
 	}
 
 	return nil, fmt.Errorf("Unknown file extension: %s", path)
@@ -125,8 +126,8 @@ func jsonWalk(obj interface{}) ([]interface{}, error) {
 	}
 }
 
-func jsonnetReader(vm *jsonnet.VM, path string) ([]runtime.Object, error) {
-	jsonnetBytes, err := ioutil.ReadFile(path)
+func jsonnetReader(fs afero.Fs, vm *jsonnet.VM, path string) ([]runtime.Object, error) {
+	jsonnetBytes, err := afero.ReadFile(fs, path)
 	if err != nil {
 		return nil, err
 	}
diff --git a/utils/openapi_test.go b/utils/openapi_test.go
index 17af0d5ce64aa6241774d63d18fcc462c19abab6..c71dd2c361a734601e95732aff30a81f8ec381da 100644
--- a/utils/openapi_test.go
+++ b/utils/openapi_test.go
@@ -18,13 +18,13 @@ package utils
 import (
 	"encoding/json"
 	"fmt"
-	"io/ioutil"
 	"path"
 	"path/filepath"
 	"strings"
 	"testing"
 
 	swagger "github.com/emicklei/go-restful-swagger12"
+	"github.com/spf13/afero"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	utilerrors "k8s.io/apimachinery/pkg/util/errors"
@@ -32,11 +32,12 @@ import (
 
 type schemaFromFile struct {
 	dir string
+	fs  afero.Fs
 }
 
 func (s schemaFromFile) SwaggerSchema(gv schema.GroupVersion) (*swagger.ApiDeclaration, error) {
 	file := path.Join(s.dir, fmt.Sprintf("schema-%s.json", gv))
-	data, err := ioutil.ReadFile(file)
+	data, err := afero.ReadFile(s.fs, file)
 	if err != nil {
 		return nil, err
 	}
@@ -50,7 +51,7 @@ func (s schemaFromFile) SwaggerSchema(gv schema.GroupVersion) (*swagger.ApiDecla
 }
 
 func TestValidate(t *testing.T) {
-	schemaReader := schemaFromFile{dir: filepath.FromSlash("../testdata")}
+	schemaReader := schemaFromFile{dir: filepath.FromSlash("../testdata"), fs: afero.NewOsFs()}
 	s, err := NewSwaggerSchemaFor(schemaReader, schema.GroupVersion{Version: "v1"})
 	if err != nil {
 		t.Fatalf("Error reading schema: %v", err)