Skip to content
lyftPublic

About

iOS touch synthesis library

Topics

Resources

Code of conduct

Contributing

Stars

708 stars

Watchers

182 watching

Forks

Repository files navigation

Hammer

If you can't touch this, it's Hammer time!

Demo

Table of Contents
  1. Introduction
  2. Installation
  3. Setup
  4. Usage
  5. AI Agents
  6. Troubleshooting
  7. License

Introduction

Hammer is a touch, stylus and keyboard synthesis library for emulating user interaction events. It enables better ways of triggering UI actions in unit tests, replicating a real world environment as much as possible.

⚠️ IMPORTANT: This library makes extensive use of private APIs and should never be included in a production app.

Installation

Requirements

Hammer requires Swift 6.0 and iOS 17.0 or later.

With SwiftPM

.package(url: "https://github.com/lyft/Hammer.git", from: "2.0.0")

With CocoaPods (deprecated)

CocoaPods support is deprecated and will not be updated. Versions up to 0.15.0 remain available on CocoaPods trunk, but newer releases are only available through SwiftPM.

pod 'HammerTests', '~> 0.15.0'

Setup

Hammer unit tests need to run in a host application to be able to generate touches. To configure this select your project in the sidebar, select your test target, and choose a host application in the general tab. The host application can be your main application or an empty wrapper like TestHost.

SwiftPM does not currently support creating applications. To use Hammer with SwiftPM frameworks you need to create an xcodeproj and setup a host application.

Usage

Hammer allows you to simulate fingers, stylus and keyboard events. It also provides various convenience methods to simulate higher level user interactions.

Hammer runs on the main actor and its API is async, so tests that use it should be async and isolated to the main actor:

@MainActor
final class MyViewTests: XCTestCase {
    func testButtonTap() async throws {
        let eventGenerator = try await EventGenerator(view: myView)
        try await eventGenerator.fingerTap(at: "my_button")
    }
}

To be able to send events to a view you must first create an EventGenerator:

// Initialize for an existing UIWindow, ensure that the window is key and visible.
let eventGenerator = try await EventGenerator(window: myWindow)

// Initialize for a UIView, automatically wrapping it in a temporary window.
let eventGenerator = try await EventGenerator(view: myView)

// Initialize for a UIViewController, automatically wrapping it in a temporary window.
let eventGenerator = try await EventGenerator(viewController: myViewController)

// Initialize for a SwiftUI view, automatically hosting it in a temporary window.
let eventGenerator = try await EventGenerator(view: MySwiftUIView())

When simulating finger or stylus touches, there are multiple ways of specifying a touch location:

  1. Default: If you don't specify a location it will use the center of the screen.
  2. Point: A CGPoint in screen coordinates.
  3. View: A reference to a UIView or UIViewController, the location will be the center of the visible part of the view.
  4. Identifier: An accessibility identifier string of a view or SwiftUI element, the location will be the center of the visible part of the view, or the activation point of the SwiftUI element.
  5. Query: An AccessibilityQuery that matches the identifier, label, value and traits of a view or SwiftUI element, with the same location as an identifier.

By default, Hammer will display simulated touches over the view. You can change this behavior for your event generator.

eventGenerator.showTouches = false

Simulating Fingers

Fingers are the most common method of user interaction on iOS. Hammer supports handling multiple fingers on the screen simultaneously, up to the limit on the device. You can specify the specific finger index you would like to use, if unspecified it will choose the most appropriate one automatically.

Primitive events are the basic building blocks of user interactions, they can be combined together to create full gestures. Some methods will allow you to specify a duration and will interpolate the changes during that time.

try await eventGenerator.fingerDown(at: CGPoint(x: 10, y: 10))
try await eventGenerator.fingerMove(to: CGPoint(x: 20, y: 10), duration: 0.5)
try await eventGenerator.fingerUp()

For convenience, Hammer provides many higher level gestures. If you don't specify a location it will automatically default to the center of the view.

try await eventGenerator.fingerTap()
try await eventGenerator.fingerDoubleTap()
try await eventGenerator.fingerLongPress()
try await eventGenerator.twoFingerTap()

Many advanced gestures are also available.

try await eventGenerator.fingerDrag(from: CGPoint(x: 10, y: 10), to: CGPoint(x: 20, y: 10), duration: 0.5)
try await eventGenerator.fingerPinch(fromDistance: 100, toDistance: 50, duration: 0.5)
try await eventGenerator.fingerRotate(angle: .pi, duration: 0.5)

Swipes move a finger in a direction, and edge swipes start from the edge of the screen like the swipe to go back in a navigation controller. Any touch that starts at the edge of the screen is marked as an edge touch, so UIScreenEdgePanGestureRecognizer recognizes it.

try await eventGenerator.fingerSwipe(direction: .up)
try await eventGenerator.fingerEdgeSwipe(direction: .right)

Simulating Stylus

Stylus is available when running on an iPad. It allows for additional properties like pressure, altitude and azimuth to be specified.

Similar to fingers, primitive events are the basic building blocks of stylus interactions.

try await eventGenerator.stylusDown(at: CGPoint(x: 10, y: 10), azimuth: 0, altitude: 0, pressure: 0.5)
try await eventGenerator.stylusMove(to: CGPoint(x: 20, y: 10), duration: 0.5)
try await eventGenerator.stylusUp()

Hammer also provides many higher level gestures for Stylus. If you don't specify a location it will automatically default to the center of the view.

try await eventGenerator.stylusTap()
try await eventGenerator.stylusDoubleTap()
try await eventGenerator.stylusLongPress()

Simulating Keyboard

Keyboard methods take an explicit KeyboardKey object or a Character. Characters will be mapped to their closest keyboard key, you must wrap them with a shift key modifier if needed. This means that specifying a lowercase "a" character is equivalent to specifying an uppercase "A", this is also true for keys with symbols.

// Explicit `KeyboardKey`
try await eventGenerator.keyDown(.letterA)
try await eventGenerator.keyUp(.letterA)

// Automatic `Character` mapping
try await eventGenerator.keyDown("a")
try await eventGenerator.keyUp("a")

// Convenience key down and up events
try await eventGenerator.keyPress(.letterA)
try await eventGenerator.keyPress("a")

To type characters or longer strings and get automatic shift wrapping you can use the keyType() methods.

try await eventGenerator.keyType("This will type the string as specified, including symbols!")

Finding a subview

When running on a full screen app or testing navigation, specifying a CGPoint in screen coordinates can be difficult. For this, Hammer provides convenience methods to find views in the hierarchy by their accessibility identifier.

let myButton = try eventGenerator.viewWithIdentifier("my_button", ofType: UIButton.self)
try await eventGenerator.fingerTap(at: myButton)

This method will throw an error if the view was not found in the hierarchy. If you're testing navigation or screen changes and you need to wait until the view appears, you can add a timeout. This will wait until the hierarchy has updated and return the view.

let myButton = try await eventGenerator.viewWithIdentifier("my_button", ofType: UIButton.self, timeout: 1)
try await eventGenerator.fingerTap(at: myButton)

You can also pass accessibility identifiers directly to the event methods.

try await eventGenerator.fingerDown(at: "my_draggable_object")
try await eventGenerator.fingerMove(to: "drop_target", duration: 0.5)
try await eventGenerator.fingerUp()

To find elements by their label, value or traits instead, use an AccessibilityQuery. The waiting and visibility methods take queries too.

try await eventGenerator.fingerTap(at: .label("Sign In", traits: .button))
try await eventGenerator.waitUntilVisible(AccessibilityQuery(label: "Wi-Fi", value: "1"))

When multiple elements match, Hammer prefers the visible ones and then the actionable ones like buttons and controls. Elements that activate at the same point, like a button and its title label, count as one. If it still can't choose it throws, unless you turn off settings.throwOnMultipleMatches to use the first match.

SwiftUI

SwiftUI views don't create a UIView for every element, so Hammer finds them through their accessibility elements instead. Give the elements you want to interact with an accessibility identifier and use it as the location, or to wait for them.

Toggle("Notifications", isOn: $isOn)
    .accessibilityIdentifier("notifications_toggle")
let eventGenerator = try await EventGenerator(view: SettingsView())
try await eventGenerator.fingerTap(at: "notifications_toggle")
try await eventGenerator.waitUntilVisible("saved_label")

A tap on an element uses its activation point, so a toggle is tapped on its switch. An element scrolled out of view is not visible, and tapping it throws. Accessibility doesn't say if an element is hidden with .hidden() or almost transparent, so Hammer treats those as visible.

Waiting

You will often need to wait for the simulator to finish displaying something on the screen or for an animation to end. Hammer provides multiple methods to wait until a view is visible on screen or if a control is hittable. They check every frame, and wait up to settings.waitTimeout unless you pass a timeout. Timeouts leave out the time the main thread is stalled without running, such as on an overloaded CI machine, so they only count the time the app had to respond.

try await eventGenerator.waitUntilVisible("my_label")
try await eventGenerator.waitUntilHittable("my_button", timeout: 1)

You can also wait for any condition, or for a fixed time.

try await eventGenerator.waitUntil(viewModel.isLoaded)
try await eventGenerator.wait(0.5)

Finishing

When you are done with an event generator, finish it. This lifts any fingers, stylus or keys still down, waits for UIKit to process that, and removes the windows Hammer added. An event generator that is released without finishing does the same, but it has to block the main thread to do it.

try await eventGenerator.finish()

Settings

EventGenerator.settings holds the settings every new event generator starts with, such as timeouts. Each event generator has its own copy in eventGenerator.settings, so a test can change it without affecting other tests.

EventGenerator.settings.waitTimeout = 5
eventGenerator.settings.eventProcessingTimeout = 10

Swift Testing

Hammer works with Swift Testing too. Tests that use it must run on the main actor, and should finish their event generators since Swift Testing has no tear down. Only the most recently created event generator that is not finished can send events, since their windows overlap, so tests that use Hammer must not run in parallel. .serialized covers one suite and the suites nested in it; tests in other suites still run in parallel unless parallel testing is turned off.

@MainActor
@Suite(.serialized)
struct MyViewTests {
    @Test
    func buttonTap() async throws {
        let eventGenerator = try await EventGenerator(view: myView)
        try await eventGenerator.fingerTap(at: "my_button")
        try await eventGenerator.finish()
    }
}

Synchronous Tests

Every async method also has a synchronous version with the same name and parameters, so tests that are not async keep working. Swift picks the synchronous version when the calling code is not async.

func testButtonTap() throws {
    let eventGenerator = try EventGenerator(view: myView)
    try eventGenerator.fingerTap(at: "my_button")
}

The synchronous versions keep the main run loop running while they wait, like XCTestCase.wait(for:timeout:). Prefer async tests for new code.

Upgrading from 1.x

  • Make tests async and add await, or keep them synchronous as they are.
  • Hammer now needs Swift 6, iOS 17 and the main actor.
  • Waits check every frame, so checkInterval is gone.
  • Timeouts are optional and default to settings.waitTimeout.

AI Agents

HammerAgent lets AI agents and scripts drive a running app with Hammer, through the hammer command line tool or its MCP server. Since it runs inside the app it finds elements by accessibility identifier, label, value or traits, including SwiftUI's, and waits for the app to process each event. It can't reach system UI such as permission alerts or the home screen.

Start it in a debug build of the app. It only runs in the simulator and accepts connections from the same device, which includes the Mac running the simulator. It rejects requests from web browsers, so websites can't control the app:

#if DEBUG
import HammerAgent

let agent = HammerAgent()
try await agent.start()
#endif

Then drive the app from the Mac:

swift run hammer describe
swift run hammer tap --identifier sign_in
swift run hammer tap --ref e12
swift run hammer type-text --identifier email --text "me@example.com"
swift run hammer screenshot --output screen.png
swift run hammer help swipe

Agents can also change views live to see how a change looks, or which one fixes a layout problem, before writing it in code:

swift run hammer inspect --ref e3
swift run hammer set-property --ref e3 --property backgroundColor --to systemBlue
swift run hammer set-constraint --constraint c4 --constant 24
swift run hammer screenshot --ref e3 --output view.png
swift run hammer revert

Commands find elements by identifier, label, value and traits, or by the ref that describe gives each view and element. A - reads a JSON object of arguments from standard input, for nested values.

hammer help <command> shows a command's arguments and examples, and hammer guide prints a markdown guide for agents with the workflow and every command the app runs. Failed commands print an error: and a hint: on how to fix the call, and exit with 2 when the call itself is invalid. To teach a coding agent to use it, copy skills/hammer-cli into your project's skills, for example .claude/skills/hammer-cli, and skills/hammer-tests for writing Hammer tests.

Run hammer mcp as a stdio MCP server to give an agent the same commands as tools. Build it first so build output doesn't mix with the MCP messages, for example with Claude Code:

swift build -c release --product hammer
claude mcp add hammer -- "$(swift build -c release --show-bin-path)/hammer" mcp

The agent listens on port 9876, or on HAMMER_AGENT_PORT for both the app and the tool (launch the app with SIMCTL_CHILD_HAMMER_AGENT_PORT to set it in the simulator). Set HAMMER_AGENT_TOKEN the same way to require a token, so other processes on the Mac can't control the app.

To run it on a physical device, call start(allowsPhysicalDevice: true), since other apps on the device can connect to it. start(allowsRemoteConnections: true) also accepts connections from other devices on the network, and needs a token.

Apps can add their own commands, which appear in the tool and as MCP tools:

struct SignInCommand: AgentCommand {
    struct Arguments: Decodable {
        let user: String
    }

    let descriptor = CommandDescriptor(name: "sign_in", summary: "Signs in as a test user.",
                                       parameters: [.init("user", .string, "The user name.", isRequired: true)])

    func run(_ arguments: Arguments, in context: AgentContext) async throws -> NoOutput {
        try await TestSession.signIn(as: arguments.user)
        return NoOutput()
    }
}

agent.register(SignInCommand())

Troubleshooting

  • The app or window is not ready for interaction

Make sure you are running your unit tests in a host application (setup instructions). To interact with a view, it must be visible on the screen and the application must have finished presenting. The error includes the check that did not pass:

  • The window's scene is not foreground active: the host app is in the background or covered by a system alert. This usually means the simulator is in a bad state; rebooting it fixes it.
  • No frame was rendered: the app is not drawing to the screen, usually for the same reasons.
  • The application is ignoring interaction events or the root view controller is being presented, dismissed or moved: a transition is still running. Wait for it to finish before creating the EventGenerator.

You can increase the time Hammer waits with EventGenerator.settings.windowReadyTimeout.

  • The touch was not delivered to the app

UIKit processed the event but did not begin a touch in any of the app's windows. Check that the window is attached to the app's active scene.

  • UIKit did not process the generated event

The main thread did not get to the event in time, for example because it is blocked by other work. You can increase the time Hammer waits with EventGenerator.settings.eventProcessingTimeout.

  • View is not in hirarchy / Unable to find view / Unable to find element

Make sure the view you specified is in the same hierarchy as the view that was used to create the EventGenerator. If you used an accessibility identifier or query, check that it was spelled correctly.

  • Found multiple elements

More than one visible and actionable element matches the identifier or query. Make the query more specific, for example by adding traits, or give the elements unique identifiers.

  • View is not visible

This means that the view is in the hierarchy but is not currently visible on screen, so it's not possible to generate touches for it. Make sure that the view is within visible bounds, not covered by other views, not hidden, and with alpha greater than 0.01.

  • View is not hittable

This means that the view is in the hierarchy and visible on screen but is not currently able to receive touches. Make sure that the view reponds to hit test in its center coordinate and user interaction is enabled.

License

Hammer is released under the Apache License. See LICENSE

About

iOS touch synthesis library

Topics

Resources

Code of conduct

Contributing

Stars

708 stars

Watchers

182 watching

Forks

Releases

Packages

Used by

Contributors

Languages