Motivation
Test doubles (sometimes referred to as mocks) are essential when writing unit tests. In Objective-C we had tools such as OCMock to ease this process. But Swift is still in its infancy and is lacking a full implementation of reflection and ambient context dependency injection (method swizzling) meaning tools like OCMock cannot work with pure Swift.
In Swift, test doubles must be written manually. The pattern I follow when writing a test double is something like this:
1 2 3 4 5 6 7 8 9 10 11 |
protocol Protocol { func simpleMethod() } // In my test target class MockProtocol: Protocol { var invokedSimpleMethod = false func simpleMethod() { invokedSimpleMethod = true } } |
This structure enables the mock objects to be reusable between tests. These mocks can also be expanded catch invoked method parameters and return stubbed objects if required.
Although there’s not much boiler plate in this example, protocols and method signatures can become more complex and are a pain to maintain when refactoring.
This problem can be solved by writing a plugin for AppCode* to ensure I don’t have to write this boiler plate ever again! So let’s get started.
Prerequisites
- Download AppCode.
- Download the community edition of IntelliJ IDEA (free). AppCode, like all other IDEs in the JetBrains family, is written in Java.
- Ensure you have the correct JDK installed. At the time of writing AppCode uses JDK 8.
Creating a plugin project
Open IntelliJ and create a new IntelliJ Platform Plugin project. By default, this template will create a plugin using the IntelliJ IDEA SDK but we want to write a Swift specific plugin so must point to the AppCode SDK.
Click the New button to add a new Project SDK and open the AppCode.app/Contents directory.
Create the project keeping all other options to their default.
Before we get started with any code there is an extra step to add the JDK to the class path of this SDK. Choose File -> Project Structure and click on SDKs. You should see the AppCode SDK. If you click on the Classpath tab you will see all the imported JAR files. Add all the JAR files from the JDK in the following directories: /Library/Java/JavaVirtualMachines/<insert-jdk-version>.jdk/Contents/Home/jre/lib , /Library/Java/JavaVirtualMachines/<insert-jdk-version>.jdk/Contents/Home/jre/lib/ext , and /Library/Java/JavaVirtualMachines/<insert-jdk-version>.jdk/Contents/Home/lib .
Click OK and we’re ready to write some code.
Creating an intention
We’re going to create an intention action. These can be executed quickly and easily within the context of the editor.
Let’s start by adding a class for our intention. Our class will extend PsiElementBaseIntentionAction and implement the IntentionAction interface. Implement all the interface methods and override the getText() method from the base class.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public class MockGeneratingIntention extends PsiElementBaseIntentionAction implements IntentionAction { @Override public boolean isAvailable(@NotNull Project project, Editor editor, @NotNull PsiElement psiElement) { return true; } @Override public void invoke(@NotNull Project project, Editor editor, @NotNull PsiElement psiElement) throws IncorrectOperationException { } @Nls @NotNull @Override public String getFamilyName() { return getText(); } @NotNull @Override public String getText() { return "Generate mock"; } } |
isAvailable() – we can control when the user can execute this action. For now we will return true so the intention is always available.
invoke() – called when the user executes our intention. We will use this method to generate our test double code.
getText() – the text that is displayed to the user. I’ve named the intention ‘Generate mock’.
Next we need to tell AppCode what actions our plugin offers and how to execute them. Open resources/META-INF/plugin.xml and add the following XML. Be sure to change the package of the class if yours differs from mine.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
<!-- 1. Must be used with AppCode --> <depends>com.intellij.modules.appcode</depends> <!-- 2. Tells AppCode where to find our intention --> <project-components> <component> <implementation-class>codes.seanhenry.intentions.MockGeneratingIntention</implementation-class> </component> </project-components> <!-- 3. Tells AppCode the type of extension and where to find it --> <extensions defaultExtensionNs="com.intellij"> <intentionAction> <className>codes.seanhenry.intentions.MockGeneratingIntention</className> <category>Generate mock</category> <descriptionDirectoryName>MockGenerator</descriptionDirectoryName> </intentionAction> </extensions> |
Running the plugin
We are now ready to run the plugin. Click on the debug icon. It will build the plugin and launch AppCode.
Let’s create a new Swift project to test our plugin.
Create a protocol with a simple method.
1 2 3 |
protocol Protocol { func simpleMethod() } |
In our test target, let’s create a mock class which implements that protocol
1 2 3 4 5 |
@testable import MockGeneratorTest class MockProtocol: Protocol { } |
We can test our intention plugin by pressing alt+enter. Our Generate mock intention appears! 🎉
Notice how the intention appears wherever the caret is.
Improving isAvailable()
I would only like the intention to appear when the caret is inside a class but since isAvailable() always returns true, the intention is available even when outside a class where it could not be executed.
Let’s change isAvailable() to only allow AppCode to show the intention when the caret is inside a class declaration.
1 2 3 4 5 |
@Override public boolean isAvailable(@NotNull Project project, Editor editor, @NotNull PsiElement psiElement) { SwiftClassDeclaration classDeclaration = PsiTreeUtil.getParentOfType(psiElement, SwiftClassDeclaration.class); return classDeclaration != null; } |
Debug the plugin again and you will see the intention is no longer available unless the caret is within a class scope. 🎉
PSI
“A PSI (Program Structure Interface) file is the root of a structure representing the contents of a file as a hierarchy of elements in a particular programming language.”
The PSI is how AppCode understands the structure of the code. With the plugin running in AppCode open AppCode -> Preferences and click on Plugins -> Browse Repositories and search for PsiViewer. Install that plugin and restart AppCode.
In AppCode open View -> Tool Windows -> PsiViewer . We can use this window to inspect the underlying code structure of our Swift file.
This is how I found out how to write the code for isAvailable() . The PsiElement argument is the element under the caret. The isAvailable() method checks to see if it has a parent of SwiftClassDeclaration type and if so makes the intention available.
Implementing the mock generator
Generating the code for the mock class can be broken down into the following steps:
- Delete any existing methods in the mock class.
- Find the protocol that the mock class implements.
- Resolve the protocol and find its methods.
- Add those methods to the mock class.
- Add an ‘invoked’ property for each method.
- Set those properties to true when a method has been invoked.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
@Override public void invoke(@NotNull Project project, Editor editor, @NotNull PsiElement psiElement) throws IncorrectOperationException { this.editor = editor; SwiftClassDeclaration classDeclaration = PsiTreeUtil.getParentOfType(psiElement, SwiftClassDeclaration.class); if (classDeclaration == null) { showErrorMessage("Could not find a class to mock."); return; } SwiftTypeInheritanceClause inheritanceClause = classDeclaration.getTypeInheritanceClause(); if (inheritanceClause == null) { showErrorMessage("Mock class does not inherit from anything."); return; } if (inheritanceClause.getReferenceTypeElementList().isEmpty()) { showErrorMessage("Could not find a protocol reference."); return; } SwiftReferenceTypeElement protocol = inheritanceClause.getReferenceTypeElementList().get(0); deleteClassStatements(classDeclaration); List<SwiftFunctionDeclaration> protocolMethods = getProtocolMethods(protocol); addProtocolFunctionsToClass(protocolMethods, classDeclaration); } private void showErrorMessage(String message) { HintManager.getInstance().showErrorHint(editor, message); } private void deleteClassStatements(SwiftClassDeclaration classDeclaration) { for (SwiftStatement statement : classDeclaration.getStatementList()) { statement.delete(); } } private List<SwiftFunctionDeclaration> getProtocolMethods(SwiftReferenceTypeElement protocol) { PsiElement resolved = protocol.resolve(); if (resolved == null) { showErrorMessage("The protocol '" + protocol.getName() + "' could not be found."); return Collections.emptyList(); } PsiFile resolvedFile = resolved.getContainingFile(); MethodGatheringVisitor visitor = new MethodGatheringVisitor(); resolvedFile.accept(visitor); return visitor.getElements(); } private void addProtocolFunctionsToClass(List<SwiftFunctionDeclaration> functions, SwiftClassDeclaration classDeclaration) { for (SwiftFunctionDeclaration function : functions) { SwiftFunctionDeclaration functionWithBody = SwiftPsiElementFactory.getInstance(function).createFunction(function.getText() + "{ " + createInvokedVariableName(function) + " = true }"); addInvocationCheckVariable(functionWithBody, classDeclaration); classDeclaration.addBefore(functionWithBody, classDeclaration.getLastChild()); } } private void addInvocationCheckVariable(SwiftFunctionDeclaration function, SwiftClassDeclaration classDeclaration) { SwiftStatement variable = SwiftPsiElementFactory.getInstance(function).createStatement("var " + createInvokedVariableName(function) + " = false"); classDeclaration.addBefore(variable, classDeclaration.getLastChild()); } private String createInvokedVariableName(SwiftFunctionDeclaration function) { String functionName = function.getName(); return "invoked" + functionName.substring(0, 1).toUpperCase() + functionName.substring(1); } |
Run the plugin again and you can see that the methods and variables are now generated automatically. We can even add more methods to the protocol and it will generate the mock code instantly. 🎉
There are lots of topics covered in the above implementation including the visitor pattern, showing errors, and more advanced PSI. If you become stuck writing your own plugin lots of detail can be found here and an active community base here. Good luck!
Be First to Comment