Using GYB to generate mock classes for testing

Tue Nov 12, 2019


I’ve previously made a switch from using MVP as my main architecture to MVVM. As a result, this article won’t be as applicable for me as it would have around six months ago, but it’s still interesting.

One of the reasons we used MVP was it allowed us to test our business logic very easily. We used passive views, which means that the view or view controller really did very little except call to its presenter. Our views and our presenters all conformed to protocols as interfaces, making mocking very easy. For example if we had a HomeViewController it would conform to HomeView which might look something like:

protocol HomeView {

	func setTitle(_ title: String)

	func setupTableView()

	func showLoading(_ show: Bool)

	func reloadTableView()

	func reloadItemAtIndex(_ index: Int)
	
}

This would be pretty standard. We can see that our HomeView has a table view, some functions to update the content, and some other functions for setting up the view in general and showing or hiding the loading state. Now when we want to mock this class, it’s easy because we have an interface that we can implement. Our mock HomeView might look something like this:

final class HomeViewMock: HomeView {
	
	var setTitleCalled = false
	var setupTableViewCalled = false
	var showLoadingCalled = false
	var reloadTableViewCalled = false
	var reloadItemAtIndexCalled = false

	func setTitle(_ title: String) {
		setTitleCalled = true
	}

	func setupTableView() {
		setupTableViewCalled = true
	}

	func showLoading(_ show: Bool) {
		showLoadingCalled = true
	}

	func reloadTableView() {
		reloadTableViewCalled = true
	}

	func reloadItemAtIndex(_ index: Int) {
		reloadItemAtIndexCalled = true
	}

}

This is a fairly straightforward mock class, but it’s annoying boilerplate code. This class is quite small, so writing the mock class didn’t take us too long, but that’s purely because this is an example. In the real world, we could have a lot more functions that we need to mock out, and this then becomes extremely tedious. Wouldn’t it be nice if there was something that could generate this for us?

GYB to the rescue!

What is GYB?

This has already been covered better than I ever could by Mattt Thompson on NSHipster, so you should head there for the in depth version. The TL;DR; version is:

GYB is an acronym from “Generate Your Boilerplate”. It’s a Swift tool for generating source code from a template file and it’s written in Python.

How can we use GYB?

If you read Mattt’s article above, you’ll see that he suggests using it as a run script, which is super handy. However, for what we’re trying to achieve, that won’t quite work. Because we’re trying to generate a mock file for testing, we’ll need to create an instance of our mock file and use that within our tests. If this just happens at build time, the compiler will be complaining to us about missing functions. These functions will be generated at build time, but the compiler doesn’t know that yet.

This means that we’re just going to use GYB as a side tool to generate our mock classes. Still very helpful, just not as integrated.

Example

Our goal here is to take a protocol and generate a mock class. This means that we’ll need to run through our protocol and parse out all the functions so that we can mock them. I’ve written a short python script to do this, which I’ll then move into my GYB file in the future. Here’s the python code

def read_file():
	f = open("HomeView.swift", "r")
	if f.mode == 'r':
		contents = f.read()
		return contents

def parse_file(contents):
	func_names = list(filter(lambda x: "func " in x, contents.split("\n")))
	raw_names_with_params = list(map(lambda x: get_func_name(x), func_names))
	raw_names = list(map(lambda x: get_raw_name(x), raw_names_with_params))
	return raw_names_with_params

def get_func_name(func):
	func_def = "func "
	start_index = func.index(func_def)+len(func_def)
	return func[start_index:]

def get_raw_name(func):
	bracket = "("
	end_index = func.index(bracket)
	return func[0:end_index]

if __name__ == "__main__":
	file = read_file()
	funcs = parse_file(file)
	for func in funcs:
		print(func)

There’s two main variables of this that are important. The raw_names_with_params and raw_names. The first is a list of all the function names including their parameters, and the second is a list of function names with the parameters stripped out. Running the python script as is will result in the output:

setTitle(_ title: String)
setupTableView()
showLoading(_ show: Bool)
reloadTableView()
reloadItemAtIndex(_ index: Int)

If we change the return type of parse_file(contents) to raw_names the output is the same, but without the parameters:

setTitle
setupTableView
showLoading
reloadTableView
reloadItemAtIndex

This will be very useful when building our mock class.

Next up we want to start on our GYB file. First we’ll need to create a file called MockGenerator.swift.gyb and now we can launch into building our template. At the top of the file we’ll add in our python code from earlier. We’re going to change a few parts around to make sure it runs correctly.

%{
	def get_func_name(func):
		func_def = "func "
		start_index = func.index(func_def)+len(func_def)
		return func[start_index:]

	def get_raw_name(func):
		bracket = "("
		end_index = func.index(bracket)
		return func[0:end_index]

	f = open("HomeView.swift", "r")
	if f.mode == 'r':
		contents = f.read()
		func_names = list(filter(lambda x: "func " in x, contents.split("\n")))
		raw_names_with_params = list(map(lambda x: get_func_name(x), func_names))
		raw_names = list(map(lambda x: get_raw_name(x), raw_names_with_params))
}%

You can see that we’ve gotten rid of our main section and are now also just doing the parsing in the if statement of the file mode check. We’ve also explicitly specified our file that we want to read. Below this we’re going to start on our class which will look like this

final class HomeViewMock: HomeView {
	
	% for name in raw_names:
	var ${name}Called = false
	% end

	% for i in range(len(raw_names)):
	func ${raw_names_with_params[i]} {
		${raw_names[i]}Called = true
	}

	% end
}

Here we can see how we’re making use of the raw_names_with_params and the raw_names to generate the protocol conformance and also our variables for verifying that the functions are called, which is what we’re interested in in a mock class. Now, let’s make this thing run! The command for running this is

gyb --line-directive '' -o "HomeViewMock.swift" "MockGenerator.swift.gyb"

After running this we should now have a new file created in the the directory titled HomeViewMock.swift that will look like this

final class HomeViewMock: HomeView {
	
	var setTitleCalled = false
	var setupTableViewCalled = false
	var showLoadingCalled = false
	var reloadTableViewCalled = false
	var reloadItemAtIndexCalled = false

	func setTitle(_ title: String) {
		setTitleCalled = true
	}

	func setupTableView() {
		setupTableViewCalled = true
	}

	func showLoading(_ show: Bool) {
		showLoadingCalled = true
	}

	func reloadTableView() {
		reloadTableViewCalled = true
	}

	func reloadItemAtIndex(_ index: Int) {
		reloadItemAtIndexCalled = true
	}

}

And we have just generated our mock file. Neat!

Improving it

It’s pretty nice that we can now generate these mock files, but it’s a bit annoying that we have to go into the GYB file to change the hardcoded string for the protocol file. Wouldn’t it be nice if we could just pass that in as a command line argument? Let’s do that instead. We’ll need to update our GYB file in a few places. First up will be where we open the protocol file on line 12, this will change to

f = open("%s.swift" %fileName, "r")

Next, on the line where we declare our class we’ll use the variable name passed in as well which will make it

final class ${(fileName)}Mock: ${(fileName)} {

And that should be it, the only difference now is we’ll need to add a parameter to our command making it

gyb -DfileName=HomeView --line-directive '' -o "HomeViewMock.swift" "MockGenerator.swift.gyb"

And now the command is pretty long, so let’s add a bash function for quicker use. I’m going to edit my .bash_profile and add the following function

mockFile() { gyb -DfileName="$1" --line-directive '' -o "$1Mock.swift" "$2"; }

Now when we go to call to GYB, we can simply write:

mockFile HomeView MockGenerator.swift.gyb

Where the first argument is the protocol we want to mock out, and the second argument is the template file we want to use.

Wrap up

Obviously there’s a lot you can do with GYB, this was just the one use case that I had found, but really its potential is limitless. There’s also other similar tools you could use instead of GYB. If you’re not too keen on writing templates with Python, you can do it in straight swift with Sourcery.


Spot something wrong? Let me know on Twitter


back · twitter · github · who is matt? · writing · projects · home