{"id":32143228,"url":"https://github.com/banksalad/axsnapshot","last_synced_at":"2025-10-21T07:55:14.352Z","repository":{"id":42055661,"uuid":"469016639","full_name":"banksalad/AXSnapshot","owner":"banksalad","description":"Text Formatted Snapshot for Accessibility Experience Testing","archived":false,"fork":false,"pushed_at":"2022-04-27T07:44:30.000Z","size":21908,"stargazers_count":112,"open_issues_count":0,"forks_count":3,"subscribers_count":10,"default_branch":"main","last_synced_at":"2025-10-01T22:40:06.808Z","etag":null,"topics":["accessibility","accessibility-testing","ios"],"latest_commit_sha":null,"homepage":"","language":"Swift","has_issues":false,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/banksalad.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2022-03-12T07:49:00.000Z","updated_at":"2025-08-25T17:04:10.000Z","dependencies_parsed_at":"2022-08-12T03:31:31.805Z","dependency_job_id":null,"html_url":"https://github.com/banksalad/AXSnapshot","commit_stats":null,"previous_names":[],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/banksalad/AXSnapshot","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/banksalad%2FAXSnapshot","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/banksalad%2FAXSnapshot/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/banksalad%2FAXSnapshot/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/banksalad%2FAXSnapshot/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/banksalad","download_url":"https://codeload.github.com/banksalad/AXSnapshot/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/banksalad%2FAXSnapshot/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":280225807,"owners_count":26293888,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-10-21T02:00:06.614Z","response_time":58,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["accessibility","accessibility-testing","ios"],"created_at":"2025-10-21T07:55:11.860Z","updated_at":"2025-10-21T07:55:14.343Z","avatar_url":"https://github.com/banksalad.png","language":"Swift","funding_links":[],"categories":[],"sub_categories":[],"readme":"# AXSnapshot [![.github/workflows/test.yml](https://github.com/banksalad/AXSnapshot/actions/workflows/test.yml/badge.svg)](https://github.com/banksalad/AXSnapshot/actions/workflows/test.yml) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbanksalad%2FAXSnapshot%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/banksalad/AXSnapshot) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbanksalad%2FAXSnapshot%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/banksalad/AXSnapshot) ![](https://badgen.net/badge/iOS/9.0+?label%3F=iOS\u0026icon=apple)\n\nText Formatted Snapshot for Accessibility Experience Testing\n\n\u003e Language Switch: [한국어](https://github.com/banksalad/AXSnapshot/blob/main/README_KR.md).\n\n## Usage \n\nAccessibility User Experience is the [sweet spot](#why) for unit testing. \n`AXSnapshot` makes it super easy to do just that.\n\n```swift\nfunc testMyViewController() async throws {\n    let viewController = MyViewController()\n    await viewController.doSomeBusinessLogic()\n    \n    XCTAssert(\n        viewController.axSnapshot() == \"\"\"\n        ------------------------------------------------------------\n        Final Result\n        button, header\n        Double Tap to see detail result\n        Actions: Retry\n        ------------------------------------------------------------\n        The question is, The answer to the Life, the Universe, and Everything\n        button\n        ------------------------------------------------------------\n        The answer is, 42\n        button\n        ------------------------------------------------------------\n        \"\"\"\n    )\n}\n```\n\n## Installation \n\n### CocoaPods\n\nIf your project uses CocoaPods, add the pod to any applicable test targets in your Podfile:\n\n```ruby\ntarget 'MyAppTests' do\n  pod 'AXSnapshot'\nend\n```\n\n### SwiftPackageManager\n\nIf you want to use AXSnapshot in any other project that uses SwiftPM, add the package as a dependency in Package.swift:\n\n```swift\ndependencies: [\n  .package(\n    url: \"https://github.com/banksalad/AXSnapshot.git\",\n    from: \"1.0.2\"\n  ),\n]\n```\n\nNext, add AXSnapshot as a dependency of your test target:\n\n```swift\ntargets: [\n  .target(name: \"MyApp\"),\n  .testTarget(\n    name: \"MyAppTests\",\n    dependencies: [\n      \"MyApp\",\n      .product(name: \"AXSnapshot\", package: \"AXSnapshot\"),\n    ]\n  )\n]\n```\n\n## Why\n\n### Because it's easy to test\n\nMany people think UI layer is hard to unit-test. \n\nFor Example, if you have ViewController like below,\n\n```swift\nclass MyViewController {\n    private let headerView = MyHeaderView()\n    private let contentView = MyContentView()\n}\n```\n\nyou _**cannot**_ test it like following\n\n```swift\nfunc testMyViewController() async throws {\n    let viewController = MyViewController()\n    let viewModel = MyViewModel()\n    viewController.bind(with: viewModel)\n    \n    await viewModel.doSomeBusinessLogic()\n    \n    // This Test cannot be build, because `headerView` property is private \n    // To build this test, you have to give up some of encapsulation\n    XCTAssert(viewController.headerView.headerText == \"Final Result\")\n}\n```\nbecause most of the properties that can be tested are not accessible, even with `@testable import` annotation.\n\nMoreover, even if you change the access level of the properties to `internal`, some problems remain.\nFor example, if you want to do the quick refactoring that changes the name of the properties, \nimplementation of the _**test has to be changed too**_, even if there is no change to the spec that the end-user can perceive. How annoying!\n\nBut, if you test the `Accessibility Experience`, instead of implementation details of *visual* UI Layer, most of those problems can be solved.\nBecause regardless of the visual ui-layer implementation, any view/viewController ultimately can be represented as `One-dimensional list of accessibilityElements`. \nThe best way to understand what this means is using `Item Chooser` in VoiceOver with \"two-finger, three taps\" gesture. \n\n\n\nhttps://user-images.githubusercontent.com/4796743/158012249-9d3d70cb-8f1d-4532-9cd1-2c2a3ffeecb4.mp4\n\n\n### Because it can test most\n\nAlso, if you are using \"MVVM\" pattern like following\n\n\n![View-ViewModel-Model](https://user-images.githubusercontent.com/4796743/158011596-9ccfd732-c4e7-4534-bf64-ebae22fec39f.png)\n\nYou are most likely testing `ViewModel`, because by testing `ViewModel`, you can not only test `ViewModel`, but also test `Model` and binding between Model-ViewModel like following diagram.\n\n\n![Test covering ViewModel, Model, and binding between Model-ViewModel](https://user-images.githubusercontent.com/4796743/158013491-f0e72650-a7a3-492b-95ca-67534f0705cf.png)\n\nThis stratedgy works, but it still cannot cover `binding between View-ViewModel`, where many potential bugs can occur. \n\nBut if you test `Accessibility Experience` of `UIView/UIViewController`, you can stretch the test-coverage to the `binding between View-ViewModel` and at least some of `View` logics.\n\n![Test covering ViewModel, Model, binding between Model-ViewModel View-ViewModel and some part of View ](https://user-images.githubusercontent.com/4796743/158013511-d1029cec-cae4-4440-a5ee-6d05b86b03ec.png)\n\n### Because it matters \n\nLast but not least, accessibility **matters**. Accessibility is not _good to have_. It's the bottom line. \nIf your app is not accessble to anyone, your app is simply not production-ready. \nBecause any of your user, regardless of her/his disability, matters just as much as any other user, because they are as much as human as anyone.\n\nBut still, accessibility is one of the easiest things to be neglected during manual testing. \nIt's hard to expect all of your testers, co-workers are familiar with VoiceOver. \nIt's even harder to expect your successor of the project is familiar with accessibility.\n\nSo, to ensure there's no regression in accessibility for a good period of time, it is very important to test it automatically. \n\n## How it Works \n\nAn `UIView` can be exposed to AssitiveTechnology when it is the first [accessibilityElement](https://developer.apple.com/documentation/objectivec/nsobject/1615141-isaccessibilityelement) in whole [responder-chain](https://www.google.com/search?client=safari\u0026rls=en\u0026q=responder+chain\u0026ie=UTF-8\u0026oe=UTF-8)\n\n![Diagram of UIView hierarchy](https://user-images.githubusercontent.com/4796743/158020789-42fd6873-258c-47cb-9929-9b3cd0fc12d6.png)\n\nSo, with this concept in mind, we can build `isExposedToAssistiveTech` logic like this \n\n```swift \nextension UIResponder {\n    var isExposedToAssistiveTech: Bool {\n        if isAccessibilityElement {\n            if allItemsInResponderChain.contains(where: { $0.isExposedToAssistiveTech }) == true {\n                return false\n            } else {\n                return true\n            }\n        } else {\n            return false\n        }\n    }\n}\n```\n\nThat's the gist of it! \n\nThe rest is just traversing all the UIView tree, and filtering views that are `exposedToAssistiveTech`, and formatting it's informations for assistiveTechnology such as [accessibilityLabel](https://www.google.com/search?client=safari\u0026rls=en\u0026q=accesbilitylabel\u0026ie=UTF-8\u0026oe=UTF-8)\n\n```swift\npublic extension UIView {\n    /// Generate text-formatted snapshot of accessibility experience\n    func axSnapshot() -\u003e String {\n        let exposedAccessibleViews = allSubViews().filter { $0.isExposedToAssistiveTech } \n        let descriptions = exposedAccessibleViews.map { element in\n            // Do some formatting on each element\n            element.accessibilityDescription\n        }\n        // Do some formatting on whole `descriptions`\n        return description\n    }\n```\n\nThe default formatting behavior for each item is declared in [generateAccessibilityDescription](https://github.com/e-sung/AXSnapshot/blob/main/Sources/AXSnapshot/AccessibilityDescription.swift) closure. To customize formatting behavior, you can replace this closure anyway you want! \n\n\n\n\n## License\n\n```\nMIT License\n\nCopyright (c) 2022 Banksalad Co., Ltd.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n```\n\n\n\n\n\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbanksalad%2Faxsnapshot","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbanksalad%2Faxsnapshot","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbanksalad%2Faxsnapshot/lists"}