Swift Refactoring - Tests

Using TDD approach to implement Swift Local Refactoring action.

Previous article - Setup

TDD or Test-driven development is a perfect approach when you face a new and unfamiliar code base.

It surely helped me, I was able to wind up a large number of tests and then iterate on actual implementation to make those tests pass.

However, learning how to write tests for Swift source code is a challenge by itself. The project is using lit or LLVM Integrated Tester and you can find more details in Apple’s own Testing Swift documentation.

Test Refactoring Kind

I’ll be making changes to the swift-refactor tool in Swift toolchain. For now I will treat that tool as a black box, i.e. I have no idea how the refactoring action is implemented.

What I do know though, is that if I give swift-refactor tool a file and specify a cursor position in that file, then it will print out a list of applicable refactoring actions at that cursor position.

So I’ll start by creating a new test file at swift/test/refactoring/RefactoringKind/collapse_nested_if_statement.swift with the following content:

Now, if I point a cursor at row 4 and column 4 in this file, I expect my new refactoring action to be applicable. If I name the refactoring action “Collapse Nested If Statement”, then this text should be the output of the following command:

So my test case is this:

Given that I run swift-refactor for collapse_nested_if_statement.swift file, row 4 and column 4, I expect the output to contain "Collapse Nested If Statement" string.

All I have to do now is write this test case using lit command. This is how it looks:

This code is put directly into collapse_nested_if_statement.swift file and lit will pick it up from there.

Here’s the detailed breakdown of the test spec:

The CHECK-COLLAPSE-NESTED-IF-STATEMENT is also defined in collapse_nested_if_statement.swift like so:

So the refactoring action first begins (Action begins), then the Collapse Nested If Statement output is written to standard output and then the action ends (Action ends).

The special -NEXT syntax is how you can define multi-line strings.

More examples of refactoring kind tests.

Cursor Position Reference

You have probably spotted a small problem with -pos=4:4 argument of the test command.

If the tests are modified, refactored or moved around, then the actual cursor position of the start of test code can change. When there’s dozens of tests in the single file, fixing up all the impacted -pos= arguments can be quite a challenge.

That’s where using cursor position reference is really helpful.

All you have to do is add a block comment like so

Now you can use "2-statements" string as a reference to the cursor position:

If the test2Statements is relocated in file or some changes happen to formatting or indentation, the tests will still point to the correct cursor position and will require no changes.

Test Refactoring Transformation

Next thing to test is the refactoring transformation itself.

The structure of these tests is like this:

  • Take the test fixture Swift file that contains code to refactor.
  • Pass it to swift-refactor and capture the output.
  • Compare output to expected code using diff utility.

Test Refactoring Transformation

I have created a new CollapseNestedIfStatement folder under swift/test/refactoring, then created basic.swift file and Outputs/basic directory.

basic.swift is where the lit tests will live and Outputs/basic is where expected outputs will be created.

Similar to refactoring kind test, I write the following Swift code in basic.swift:

After the refactoring action has been applied, I expect the code to look like this:

so this is what I put into Outputs/basic/2-statements.swift.expected.

Now I have to write lit test spec in basic.swift:

The test begins by cleaning up output directory with rm -rf %t.result && mkdir -p %t.result command. %t in this case is expanded to the test name.

Next the test runs %refactor command which expands to swift-refactor. The command is almost identical to the one in refactoring kind tests, except that this time I pass -collapse-nested-if-statement to tell swift-refactor to apply this particular refactoring action.

The -collapse-nested-if-statement command line option hasn’t been implemented yet, but remember that this is TDD.

The output of swift-refactor is saved to %t.result/2-statements.swift.

Finally, the output is compared to expected Swift code using diff utility.

-u is used to compare 3 lines of unified context, while -B is used to ignore blank lines.

%S is expanded to the parent directory of the current (basic.swift) file.

That’s the basic approach, next step is to add more test code and expected outputs. Here’s an example.

Running Tests

Once you have enough tests and made your first changes to implement the new refactoring action, you can rebuild only the swift-refactor tool:

where SWIFT_BUILD_DIR is set to location of Swift build folder, for example build/Ninja-RelWithDebInfoAssert+swift-DebugAssert/swift-macosx-x86_64/.

To speed up the development process even further, you can use --filter option of lit.py to run selected tests only. For example, to run only refactoring kind tests from collapse_nested_if_statement.swift:

Or to run refactoring transformation tests from CollapseNestedIfStatement/basic.swift:

Now you are all set for the usual “Build, Test, Repeat” development cycle.


Next - Implementation - Part 1

Published: February 03 2019

blog comments powered by Disqus