├── AutoHotUnit.ahk ├── LICENSE ├── README.md ├── ahkpm.json ├── example-output.png └── example ├── math.ahk ├── math.test.ahk └── tests.ahk /AutoHotUnit.ahk: -------------------------------------------------------------------------------- 1 | #SingleInstance Force 2 | #Warn All, StdOut 3 | FileEncoding("UTF-8") 4 | 5 | global ahu := AutoHotUnitManager(AutoHotUnitCLIReporter()) 6 | 7 | class AutoHotUnitSuite { 8 | assert := AutoHotUnitAsserter() 9 | 10 | ; Executed once before any tests execute 11 | beforeAll() { 12 | } 13 | 14 | ; Executed once before each test is executed 15 | beforeEach() { 16 | } 17 | 18 | ; Executed once after each test is executed 19 | afterEach() { 20 | } 21 | 22 | ; Executed once after all tests have executed 23 | afterAll() { 24 | } 25 | } 26 | 27 | class AutoHotUnitManager { 28 | Suite := AutoHotUnitSuite 29 | suites := [] 30 | 31 | __New(reporter) { 32 | this.reporter := reporter 33 | } 34 | 35 | RegisterSuite(SuiteSubclasses*) 36 | { 37 | for i, subclass in SuiteSubclasses { 38 | this.suites.push(subclass) 39 | } 40 | } 41 | 42 | RunSuites() { 43 | this.reporter.onRunStart() 44 | for i, suiteClass in this.suites { 45 | suiteInstance := suiteClass() 46 | suiteName := suiteInstance.__Class 47 | this.reporter.onSuiteStart(suiteName) 48 | 49 | testNames := [] 50 | for propertyName in suiteInstance.base.OwnProps() { 51 | ; If the property name starts with an underscore, skip it 52 | underScoreIndex := InStr(propertyName, "_") 53 | if (underScoreIndex == 1) { 54 | continue 55 | } 56 | 57 | ; If the property name is one of the Suite base class methods, skip it 58 | if (propertyName == "beforeAll" || propertyName == "beforeEach" || propertyName == "afterEach" || propertyName == "afterAll") { 59 | continue 60 | } 61 | 62 | if (GetMethod(suiteInstance, propertyName) is Func) { 63 | testNames.push(propertyName) 64 | } 65 | } 66 | 67 | 68 | 69 | try { 70 | suiteInstance.beforeAll() 71 | } catch Error as e { 72 | this.reporter.onTestResult("beforeAll", "failed", "", e) 73 | continue 74 | } 75 | 76 | for j, testName in testNames { 77 | try { 78 | suiteInstance.beforeEach() 79 | } catch Error as e { 80 | this.reporter.onTestResult(testName, "failed", "beforeEach", e) 81 | continue 82 | } 83 | 84 | try { 85 | local method := GetMethod(suiteInstance, testName) 86 | 87 | method(suiteInstance) 88 | } catch Error as e { 89 | this.reporter.onTestResult(testName, "failed", "test", e) 90 | continue 91 | } 92 | 93 | try { 94 | suiteInstance.afterEach() 95 | } catch Error as e { 96 | this.reporter.onTestResult(testName, "failed", "afterEach", e) 97 | continue 98 | } 99 | 100 | this.reporter.onTestResult(testName, "passed", "", "") 101 | } 102 | 103 | try { 104 | suiteInstance.afterAll() 105 | } catch Error as e { 106 | this.reporter.onTestResult("afterAll", "failed", "", e) 107 | continue 108 | } 109 | 110 | this.reporter.onSuiteEnd(suiteName) 111 | } 112 | this.reporter.onRunComplete() 113 | } 114 | } 115 | 116 | class AutoHotUnitCLIReporter { 117 | currentSuiteName := "" 118 | failures := [] 119 | red := "" 120 | green := "" 121 | reset := "" 122 | 123 | printLine(str) { 124 | FileAppend(str, "*", "UTF-8") 125 | FileAppend("`r`n", "*") 126 | } 127 | 128 | onRunStart() { 129 | this.printLine("Starting test run`r`n") 130 | } 131 | 132 | onSuiteStart(suiteName) { 133 | this.printLine(suiteName ":") 134 | this.currentSuiteName := suiteName 135 | } 136 | 137 | onTestStart(testName) { 138 | } 139 | 140 | onTestResult(testName, status, where, error) { 141 | if (status != "passed" && status != "failed") { 142 | throw Error("Invalid status: " . status) 143 | } 144 | 145 | prefix := this.green . "." 146 | if (status == "failed") { 147 | prefix := this.red . "x" 148 | } 149 | 150 | this.printLine(" " prefix " " testName " " status this.reset) 151 | if (status == "failed") { 152 | this.printLine(this.red " " error.Message this.reset) 153 | this.failures.push(this.currentSuiteName "." testName " " where " failed:`r`n " error.Message) 154 | } 155 | } 156 | 157 | onSuiteEnd(suiteName) { 158 | } 159 | 160 | onRunComplete() { 161 | this.printLine("") 162 | postfix := "All tests passed." 163 | if (this.failures.Length > 0) { 164 | postfix := this.failures.Length . " test(s) failed." 165 | } 166 | this.printLine("Test run complete. " postfix) 167 | 168 | if (this.failures.Length > 0) { 169 | this.printLine("") 170 | } 171 | 172 | for i, failure in this.failures { 173 | this.printLine(this.red failure this.reset) 174 | } 175 | 176 | Exit(this.failures.Length) 177 | } 178 | } 179 | 180 | class AutoHotUnitAsserter { 181 | static deepEqual(actual, expected) { 182 | if (actual is Array && expected is Array) { 183 | if (actual.Length != expected.Length) { 184 | return false 185 | } 186 | 187 | for i, actualItem in actual { 188 | if (!this.deepEqual(actualItem, expected[i])) { 189 | return false 190 | } 191 | } 192 | 193 | return true 194 | } 195 | 196 | return actual == expected 197 | } 198 | 199 | static getPrintableValue(value) { 200 | if (value is Array) { 201 | str := "[" 202 | for i, item in value { 203 | if (i > 1) { 204 | str .= ", " 205 | } 206 | str .= this.getPrintableValue(item) 207 | } 208 | str .= "]" 209 | return str 210 | } 211 | 212 | return value 213 | } 214 | 215 | equal(actual, expected) { 216 | if (!AutoHotUnitAsserter.deepEqual(actual, expected)) { 217 | throw Error("Assertion failed: " . AutoHotUnitAsserter.getPrintableValue(actual) . " != " . AutoHotUnitAsserter.getPrintableValue(expected)) 218 | } 219 | } 220 | 221 | notEqual(actual, expected) { 222 | if (actual == expected) { 223 | throw Error("Assertion failed: " . actual . " == " . expected) 224 | } 225 | } 226 | 227 | isTrue(actual) { 228 | if (actual != true) { 229 | throw Error("Assertion failed: " . actual . " is not true") 230 | } 231 | } 232 | 233 | isFalse(actual) { 234 | if (actual == true) { 235 | throw Error("Assertion failed: " . actual . " is not false") 236 | } 237 | } 238 | 239 | isEmpty(actual) { 240 | if (actual != "") { 241 | throw Error("Assertion failed: " . actual . " is not empty") 242 | } 243 | } 244 | 245 | notEmpty(actual) { 246 | if (actual == "") { 247 | throw Error("Assertion failed: " . actual . " is empty") 248 | } 249 | } 250 | 251 | fail(message) { 252 | throw Error("Assertion failed: " . message) 253 | } 254 | 255 | isAbove(actual, expected) { 256 | if (actual <= expected) { 257 | throw Error("Assertion failed: " . actual . " is not above " . expected) 258 | } 259 | } 260 | 261 | isAtLeast(actual, expected) { 262 | if (actual < expected) { 263 | throw Error("Assertion failed: " . actual . " is not at least " . expected) 264 | } 265 | } 266 | 267 | isBelow(actual, expected) { 268 | if (actual >= expected) { 269 | throw Error("Assertion failed: " . actual . " is not below " . expected) 270 | } 271 | } 272 | 273 | isAtMost(actual, expected) { 274 | if (actual > expected) { 275 | throw Error("Assertion failed: " . actual . " is not at most " . expected) 276 | } 277 | } 278 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Joshua Clanton 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AutoHotUnit - A unit testing framework for AutoHotkey 2 | 3 | While there have been other unit testing frameworks for AutoHotkey, I found most 4 | of them a bit clumsy to work with. This is my attempt to modernize the 5 | way AutoHotkey unit testing is done. 6 | 7 | ## Features 8 | 9 | - Outputs colorized results to the command line 10 | - Natural way of organizing tests and test suites with classes 11 | - Full access to test lifecycle events: `beforeAll`, `beforeEach`, `afterEach`, `afterAll`. 12 | - Provides a proper exit code, making it suitable for use in a CI environment like GitHub actions 13 | - An easy-to-understand assertion library 14 | - An extensible reporter API, allowing custom reporters for alternate formats (e.g. jUnit XML, notify, etc.) 15 | 16 | ## Installation 17 | 18 | The recommended approach for installation is to use [ahkpm][]. 19 | 20 | To get the latest version of AutoHotUnit (which requires AutoHotkey 2): 21 | 22 | `ahkpm install gh:joshuacc/AutoHotUnit` 23 | 24 | To get previous versions, which supports AutoHotkey 1: 25 | 26 | `ahkpm install gh:joshuacc/AutoHotUnit@1` 27 | 28 | ## Usage 29 | 30 | A complete usage guide can be found below. For a working example, see the 31 | `example` directory. 32 | 33 | ### Create the test entry point 34 | 35 | Create a file named `tests.ahk` in your project directory. 36 | 37 | ```autohotkey 38 | ; Include AutoHotUnit. The path may be different on your system. 39 | #Include, %A_ScriptDir%\ahkpm-modules\github.com\joshuacc\AutoHotUnit\AutoHotUnit.ahk 40 | 41 | ; Include each test file 42 | ; See individual test files for more information 43 | #Include, %A_ScriptDir%\math.test.ahk 44 | 45 | ; Run all test suites 46 | ahu.RunSuites() 47 | 48 | ; To execute all tests from the command line, use the following command: 49 | ; autohotkey tests.ahk | echo 50 | ; The echo is required in order to print output to the terminal. 51 | ``` 52 | 53 | 54 | ### Defining test suites 55 | 56 | Create a new file containing one or more test suites. I recommend naming it 57 | `*.test.ahk` 58 | 59 | ```autohotkey 60 | ; Include the file whose functionality you are testing 61 | #Include, %A_ScriptDir%\math.ahk 62 | 63 | ; Register the test suite with AutoHotUnit 64 | ahu.RegisterSuite(MathSuite) 65 | 66 | ; Define your test suite, extending from the AutoHotUnitSuite class 67 | class MathSuite extends AutoHotUnitSuite { 68 | multipliesCorrectly() { 69 | this.assert.equal(Multiply(5, 3), 15) 70 | } 71 | 72 | addsCorrectly() { 73 | this.assert.equal(Add(1, 2), 3) 74 | } 75 | } 76 | ``` 77 | 78 | ### Running the tests 79 | 80 | At the command line run `autohotkey tests.ahk | echo`. Piping to `echo` is 81 | needed so AutoHotkey knows we want the output to show up on the command line. 82 | 83 | Each method in your suite will be executed. If the results match the assertions, 84 | then you will see green in your terminal. If not, you will see red. For example: 85 | 86 | ![An example of a terminal showing both a failing and passing test](example-output.png) 87 | 88 | ### Using assertions 89 | 90 | Each AutoHotUnit test suite has access to `this.assert`, which provides several 91 | types of assertions you can use to describe expected behavior in your test 92 | suite. And the available assertions will be expanded in the future. 93 | 94 | The current list: 95 | 96 | - `equal(actual, expected)` 97 | - `notEqual(actual, expected)` 98 | - `isTrue(actual)` 99 | - `isFalse(actual)` 100 | - `isEmpty(actual)` 101 | - `notEmpty(actual)` 102 | - `fail(message)` 103 | - `isAbove(actual, expected)` 104 | - `isAtLeast(actual, expected)` 105 | - `isBelow(actual, expected)` 106 | - `isAtMost(actual, expected)` 107 | 108 | ### Suite lifecycle methods 109 | 110 | Each AutoHotUnit test suite supports four lifecycle methods: 111 | 112 | - `beforeAll`: Executed once per suite before any tests or other lifecycle methods. Suitable for per-suite setup. 113 | - `beforeEach`: Executed once before each test. Suitable for per-test setup. 114 | - `afterEach`: Executed once after each test. Suitable for per-test cleanup. 115 | - `afterAll`: Executed once per suite after all tests and other lifecycle methods. Suitable for per-suite cleanup. 116 | 117 | 118 | ```autohotkey 119 | class MathSuite extends AutoHotUnitSuite { 120 | beforeAll() { 121 | ; Overall suite setup goes here 122 | } 123 | 124 | beforeEach() { 125 | ; Per-test setup goes here 126 | } 127 | 128 | afterEach() { 129 | ; Per-test cleanup goes here 130 | } 131 | 132 | afterAll() { 133 | ; Overall suite cleanup goes here 134 | } 135 | 136 | multipliesCorrectly() { 137 | this.assert.equal(Multiply(5, 3), 15) 138 | } 139 | 140 | addsCorrectly() { 141 | this.assert.equal(Add(1, 2), 3) 142 | } 143 | } 144 | ``` 145 | 146 | ### Defining helper methods for your suite 147 | 148 | AutoHotUnit also provides a way for you to define helper methods on your class 149 | which will not be executed as tests. To add a helper method, just add a method 150 | as normal, but prefix it with an underscore. 151 | 152 | ```autohotkey 153 | class MathSuite extends AutoHotUnitSuite { 154 | _getRandomNumber() { 155 | ; This method will be skipped by AutoHotUnit, but is still available 156 | ; for you to use as needed within your test suite 157 | } 158 | 159 | multipliesCorrectly() { 160 | this.assert.equal(Multiply(5, 3), 15) 161 | } 162 | } 163 | ``` 164 | 165 | 166 | [ahkpm]:https://ahkpm.dev 167 | -------------------------------------------------------------------------------- /ahkpm.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.1.0", 3 | "description": "A unit testing framework for AutoHotkey", 4 | "repository": "https://github.com/joshuacc/AutoHotUnit", 5 | "website": "https://github.com/joshuacc/AutoHotUnit", 6 | "license": "MIT", 7 | "issueTracker": "https://github.com/joshuacc/AutoHotUnit/issues", 8 | "include": "AutoHotUnit.ahk", 9 | "author": { 10 | "name": "Joshua Clanton", 11 | "email": "", 12 | "website": "https://joshuaclanton.dev" 13 | }, 14 | "dependencies": {} 15 | } -------------------------------------------------------------------------------- /example-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuacc/AutoHotUnit/c1304bc70feccc60c32839482839380493a5e230/example-output.png -------------------------------------------------------------------------------- /example/math.ahk: -------------------------------------------------------------------------------- 1 | #SingleInstance Force 2 | 3 | Add(a, b) { 4 | return a + b 5 | } 6 | 7 | Multiply(a, b) { 8 | return a * b 9 | } -------------------------------------------------------------------------------- /example/math.test.ahk: -------------------------------------------------------------------------------- 1 | #SingleInstance Force 2 | 3 | #Include "%A_ScriptDir%\math.ahk" 4 | 5 | ; Register the test suite with AutoHotUnit 6 | ahu.RegisterSuite(MathSuite) 7 | 8 | ; Define your test suite, extending from the AutoHotUnitSuite class 9 | class MathSuite extends AutoHotUnitSuite { 10 | multipliesCorrectly() { 11 | this.assert.equal(Multiply(5, 3), 15) 12 | } 13 | 14 | addsCorrectly() { 15 | this.assert.equal(Add(1, 2), 3) 16 | } 17 | } -------------------------------------------------------------------------------- /example/tests.ahk: -------------------------------------------------------------------------------- 1 | #SingleInstance Force 2 | SendMode("Input") 3 | #Warn 4 | 5 | ; Include AutoHotUnit. The path will be different on your system. 6 | #Include "%A_ScriptDir%\..\AutoHotUnit.ahk" 7 | 8 | ; Include each test file 9 | ; See individual test files for more information 10 | #Include "%A_ScriptDir%\math.test.ahk" 11 | 12 | ; Run all test suites 13 | ahu.RunSuites() 14 | 15 | ; To execute all tests from the command line, use the following command: 16 | ; autohotkey tests.ahk | echo 17 | ; The echo is required in order to print output to the terminal. --------------------------------------------------------------------------------