From 82e520e7fe24a4cf946a7472bc7f30923f45593f Mon Sep 17 00:00:00 2001 From: Peter Krassay Date: Sat, 6 Jun 2026 15:33:43 -0600 Subject: [PATCH 1/4] Add opt-in getter call-count tracking for protocol properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Get-only protocol properties render as plain stored properties that record no reads, so a SwiftyMocky `Verify(mock, .once, .someProperty)` getter check has no `…GetCallCount` to translate to. This adds opt-in, per-property getter call-count tracking, parallel to the existing setter `SetCallCount`. Opt in per property via `@mockable(getter: state = true)` (with an `all` wildcard and `name = false` opt-out, mirroring `combine:`/`rx:`), or project-wide via `--enable-getter-history`; explicit annotations win over the flag. A tracked property becomes a counting computed accessor over a `_name` backing store, and stays settable so it can still be stubbed: private(set) var stateGetCallCount = 0 private var _state: T! // or `= ` when defaultable var state: T { get { stateGetCallCount += 1; return _state } set { _state = newValue } // + SetCallCount when get-set } Scope and guards (protocol mocks only): - Class mocks (--mock-all) are a no-op (overriding `var` would break definite init of the backing store). - Combine/Rx/@Published-intercepted properties and weak/dynamic-modified properties are excluded by type/modifier; direct Rx subjects stay eligible. - Init writes the backing store directly (via `backingName`) so neither counter moves during initialization; an explicit init param sharing a tracked backing name is de-duplicated against the template-emitted `_name`. - `UniqueModelGenerator` strips the new `GetCallCount` suffix before the generic `CallCount` so a re-ingested `stateGetCallCount` keys back to `state`. Per-property opt-in resolves to an explicit `GetterHistory` enum (enabled/disabled/unspecified); `.unspecified` defers to the flag. Counters are plain properties (actor-isolated on actors, unsynchronized under @unchecked Sendable), matching the existing property `SetCallCount` model. Adds Tests/TestGetterHistory with @Fixture string-diff fixtures (compile-checked nested mocks) across specific/all/opt-out, get-only/get-set, defaultable/ optional/non-defaultable, static, global-flag, allow-set-call-count, exclusion, Rx-subject, class-mock no-op, processed-merge, init-collision dedup, existential `any`/namespaced types, actor, and Sendable, plus runtime tests that read the compiled fixture mocks to assert counters increment on read and stay 0 after init. Full suite green (171 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 29 ++ Sources/Mockolo/Executor.swift | 5 + .../Models/ParsedEntity.swift | 5 +- .../Models/VariableModel.swift | 56 +++ .../Operations/Generator.swift | 4 +- .../Operations/UniqueModelGenerator.swift | 4 + .../Parsers/SwiftSyntaxExtensions.swift | 12 + .../Templates/NominalTemplate.swift | 37 +- .../Templates/VariableTemplate.swift | 20 + .../Utils/StringExtensions.swift | 3 + Tests/MockoloTestCase.swift | 19 +- .../FixtureGetterHistory.swift | 458 ++++++++++++++++++ .../FixtureGetterHistorySendable.swift | 28 ++ .../GetterHistoryTests.swift | 157 ++++++ 14 files changed, 816 insertions(+), 21 deletions(-) create mode 100644 Tests/TestGetterHistory/FixtureGetterHistory.swift create mode 100644 Tests/TestGetterHistory/FixtureGetterHistorySendable.swift create mode 100644 Tests/TestGetterHistory/GetterHistoryTests.swift diff --git a/README.md b/README.md index 3931ab5f..daa6214b 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ OPTIONS: --custom-imports If set, custom module imports (separated by a space) will be added to the final import statement list. --enable-args-history Whether to enable args history for all functions (default = false). To enable history per function, use the 'history' keyword in the annotation argument. + --enable-getter-history Whether to enable getter call-count tracking for all protocol properties (default = false). To enable per property, use the 'getter' keyword in the annotation argument. --disable-combine-default-values Whether to disable generating Combine streams in mocks (default = false). Set this to true to control how your streams are created in your mocks. --exclude-imports @@ -358,6 +359,34 @@ public class FooMock: Foo { and also, enable the arguments captor for all functions if you passed `--enable-args-history` arg to `mockolo` command. > NOTE: The arguments captor only supports singular types (e.g. variable, tuple). The closure variable is not supported. +### Getter Call Count History + +Get-only properties are rendered as plain stored properties that record no reads. To count getter calls (the analogue of a setter's `…SetCallCount`), opt in with the `getter` keyword: + +```swift +/// @mockable(getter: state = true) +public protocol Foo { + var state: Int { get } +} +``` + +This will generate: + +```swift +public class FooMock: Foo { + public private(set) var stateGetCallCount = 0 + private var _state: Int = 0 + public var state: Int { + get { stateGetCallCount += 1; return _state } + set { _state = newValue } // still settable, so it can be stubbed + } +} +``` + +Use `all = true` to opt every property in, and `name = false` to opt one back out. The `--enable-getter-history` flag enables tracking project-wide; an explicit `name = false` (or `all = false`) still wins over it. This mirrors SwiftyMocky's `Verify(mock, .once, .someProperty)`, which maps onto `mock.somePropertyGetCallCount`. + +> NOTE: Protocol mocks only (a no-op for `--mock-all` class mocks). Combine/Rx/`@Published`-intercepted properties (`AnyPublisher`, `Observable`, `rx:`/`combine:`-annotated) and `weak`/`dynamic` properties are never tracked, since they cannot be backed by a computed accessor. + ### Combine's AnyPublisher To generate mocks for Combine's AnyPublisher: diff --git a/Sources/Mockolo/Executor.swift b/Sources/Mockolo/Executor.swift index b9de486b..11760d86 100644 --- a/Sources/Mockolo/Executor.swift +++ b/Sources/Mockolo/Executor.swift @@ -47,6 +47,10 @@ struct Executor: ParsableCommand { help: "Whether to enable args history for all functions (default = false). To enable history per function, use the 'history' keyword in the annotation argument.") private var enableArgsHistory: Bool = false + @Flag(name: .long, + help: "Whether to enable getter call-count tracking for all protocol properties (default = false). To enable per property, use the 'getter' keyword in the annotation argument.") + private var enableGetterHistory: Bool = false + @Flag(name: .long, help: "Whether to disable generating Combine streams in mocks (default = false). Set this to true to control how your streams are created in your mocks.") private var disableCombineDefaultValues: Bool = false @@ -201,6 +205,7 @@ struct Executor: ParsableCommand { useTemplateFunc: useTemplateFunc, allowSetCallCount: allowSetCallCount, enableFuncArgsHistory: enableArgsHistory, + enableGetterHistory: enableGetterHistory, disableCombineDefaultValues: disableCombineDefaultValues, mockFinal: mockFinal, testableImports: testableImports, diff --git a/Sources/MockoloFramework/Models/ParsedEntity.swift b/Sources/MockoloFramework/Models/ParsedEntity.swift index 020e0ff5..448e9fa3 100644 --- a/Sources/MockoloFramework/Models/ParsedEntity.swift +++ b/Sources/MockoloFramework/Models/ParsedEntity.swift @@ -132,6 +132,7 @@ struct AnnotationMetadata { var typeAliases: [String: String]? var varTypes: [String: String]? var funcsWithArgsHistory: [String]? + var varsWithGetterHistory: [String: Bool]? var modifiers: [String: Modifier]? var combineTypes: [String: CombineType]? } @@ -142,12 +143,14 @@ struct GenerationArguments { var mockFinal: Bool var enableFuncArgsHistory: Bool var disableCombineDefaultValues: Bool + var enableGetterHistory: Bool static let `default` = GenerationArguments( useTemplateFunc: false, allowSetCallCount: false, mockFinal: false, enableFuncArgsHistory: false, - disableCombineDefaultValues: false + disableCombineDefaultValues: false, + enableGetterHistory: false ) } diff --git a/Sources/MockoloFramework/Models/VariableModel.swift b/Sources/MockoloFramework/Models/VariableModel.swift index d9c4d5f2..88ab889d 100644 --- a/Sources/MockoloFramework/Models/VariableModel.swift +++ b/Sources/MockoloFramework/Models/VariableModel.swift @@ -10,6 +10,13 @@ final class VariableModel: Model { case computed(GetterEffects) } + // Resolved getter-tracking opt-in; `.unspecified` defers to `--enable-getter-history`. + enum GetterHistory { + case enabled + case disabled + case unspecified + } + let name: String let type: SwiftType? let offset: Int64 @@ -22,6 +29,7 @@ final class VariableModel: Model { let storageKind: MockStorageKind let rxTypes: [String: String]? let customModifiers: [String: Modifier]? + let getterHistory: GetterHistory let modelDescription: String? var combineType: CombineType? @@ -52,6 +60,7 @@ final class VariableModel: Model { offset: Int64, rxTypes: [String: String]?, customModifiers: [String: Modifier]?, + getterHistory: GetterHistory, modelDescription: String?, combineType: CombineType?, processed: Bool) { @@ -64,6 +73,7 @@ final class VariableModel: Model { self.processed = processed self.rxTypes = rxTypes self.customModifiers = customModifiers + self.getterHistory = getterHistory self.accessLevel = acl ?? "" self.attributes = nil self.modelDescription = modelDescription @@ -132,3 +142,49 @@ final class VariableModel: Model { arguments: arguments) } } + +extension VariableModel { + // Combine `AnyPublisher` intercepted by the publisher template (no `_name` backing). + var isCombineVariable: Bool { + return type?.isNominal(named: .anyPublisher) == true + } + + // Rx stream intercepted by the Rx template (`rx:` override or bare `Observable<…>`). + // Direct `BehaviorSubject`/`ReplaySubject`/`BehaviorRelay` props stay eligible. + var isRxVariable: Bool { + if let rxTypes, !rxTypes.isEmpty, + type?.parseRxVar(overrides: rxTypes, overrideKey: name, isInitParam: true) != nil { + return true + } + return type?.typeName.range(of: String.observableLeftAngleBracket) != nil + } + + // Protocol-mock stored getters only; intercepted, property-wrapped, weak/dynamic and processed props are excluded. + func shouldTrackGetter(force: Bool, context: RenderContext) -> Bool { + guard !processed, + context.annotatedTypeKind == .protocol, + case .stored = storageKind, + !isCombineVariable, + !isRxVariable, + propertyWrapper == nil, + customModifiers?[name] == nil + else { + return false + } + switch getterHistory { + case .enabled: return true + case .disabled: return false + case .unspecified: return force + } + } + + // `_name` when a stored getter is tracked, else the existing name; computed/untracked props are unchanged. + func backingName(force: Bool, context: RenderContext) -> String { + guard case .stored = storageKind, + shouldTrackGetter(force: force, context: context) + else { + return underlyingName + } + return "\(String.underlyingVarPrefix)\(name)" + } +} diff --git a/Sources/MockoloFramework/Operations/Generator.swift b/Sources/MockoloFramework/Operations/Generator.swift index 6a8becb0..245985d0 100644 --- a/Sources/MockoloFramework/Operations/Generator.swift +++ b/Sources/MockoloFramework/Operations/Generator.swift @@ -34,6 +34,7 @@ public func generate(sourceDirs: [String], useTemplateFunc: Bool, allowSetCallCount: Bool, enableFuncArgsHistory: Bool, + enableGetterHistory: Bool, disableCombineDefaultValues: Bool, mockFinal: Bool, testableImports: [String], @@ -155,7 +156,8 @@ public func generate(sourceDirs: [String], allowSetCallCount: allowSetCallCount, mockFinal: mockFinal, enableFuncArgsHistory: enableFuncArgsHistory, - disableCombineDefaultValues: disableCombineDefaultValues + disableCombineDefaultValues: disableCombineDefaultValues, + enableGetterHistory: enableGetterHistory ) ) { (mockString: String, offset: Int64) in candidates.append((mockString, offset)) diff --git a/Sources/MockoloFramework/Operations/UniqueModelGenerator.swift b/Sources/MockoloFramework/Operations/UniqueModelGenerator.swift index cb76687a..0a9c4a4f 100644 --- a/Sources/MockoloFramework/Operations/UniqueModelGenerator.swift +++ b/Sources/MockoloFramework/Operations/UniqueModelGenerator.swift @@ -45,6 +45,10 @@ private func generateUniqueModels(key: String, if let rng = name.range(of: String.setCallCountSuffix) { return (String(name[name.startIndex.. String { processCombineAliases(entities: entities) - + let acl = accessLevel.isEmpty ? "" : accessLevel + " " + // `_name` backings for getter-tracked stored props: used to dedup init-param vars and to + // assign the backing in init. Computed after `processCombineAliases` so wrapper exclusions resolve. + let initRenderContext = RenderContext( + enclosingType: type, + annotatedTypeKind: declKindOfMockAnnotatedBaseType, + mockDeclKind: declKind, + requiresSendable: requiresSendable + ) + let getterTrackedBackingNames = Set(entities.compactMap { (_, model) -> String? in + guard let v = model as? VariableModel, !v.isStatic, + case .stored = v.storageKind, + v.shouldTrackGetter(force: arguments.enableGetterHistory, context: initRenderContext) + else { return nil } + return v.backingName(force: arguments.enableGetterHistory, context: initRenderContext) + }) + let (aliasItems, typeparameters, whereClauses, @@ -62,12 +78,8 @@ extension NominalModel { declaredInits: declaredInits, acl: acl, declKindOfMockAnnotatedBaseType: declKindOfMockAnnotatedBaseType, - context: .init( - enclosingType: type, - annotatedTypeKind: declKindOfMockAnnotatedBaseType, - mockDeclKind: declKind, - requiresSendable: requiresSendable - ), + getterTrackedBackingNames: getterTrackedBackingNames, + context: initRenderContext, arguments: arguments ) @@ -110,6 +122,7 @@ extension NominalModel { declaredInits: [MethodModel], acl: String, declKindOfMockAnnotatedBaseType: NominalTypeDeclKind, + getterTrackedBackingNames: Set, context: RenderContext, arguments: GenerationArguments ) -> String { @@ -167,7 +180,7 @@ extension NominalModel { paramsAssign = initParamCandidates.map { (element: VariableModel) in switch element.storageKind { case .stored: - return "\(2.tab)self.\(element.underlyingName) = \(element.name.safeName)" + return "\(2.tab)self.\(element.backingName(force: arguments.enableGetterHistory, context: context)) = \(element.name.safeName)" case .computed: return "\(2.tab)self.\(element.name)\(String.handlerSuffix) = { \(element.name.safeName) }" } @@ -187,7 +200,11 @@ extension NominalModel { }, by: \.name ) - .compactMap { (name: String, params: [ParamModel]) in + .compactMap { (name: String, params: [ParamModel]) -> String? in + // Skip a `_name` the variable template already emits for a tracked getter. + if getterTrackedBackingNames.contains(params[0].underlyingName) { + return nil + } let shouldErase = params.contains { params[0].type.typeName != $0.type.typeName } return params[0].asInitVarDecl(eraseType: shouldErase) } @@ -218,7 +235,7 @@ extension NominalModel { """ } else { let paramsAssign = m.params.map { param in - let underVars = initParamCandidates.compactMap { return $0.name.safeName == param.name.safeName ? $0.underlyingName : nil} + let underVars = initParamCandidates.compactMap { return $0.name.safeName == param.name.safeName ? $0.backingName(force: arguments.enableGetterHistory, context: context) : nil} if let underVar = underVars.first { return "\(2.tab)self.\(underVar) = \(param.name.safeName)" } else { diff --git a/Sources/MockoloFramework/Templates/VariableTemplate.swift b/Sources/MockoloFramework/Templates/VariableTemplate.swift index 6917eb59..f749a0c2 100644 --- a/Sources/MockoloFramework/Templates/VariableTemplate.swift +++ b/Sources/MockoloFramework/Templates/VariableTemplate.swift @@ -57,12 +57,32 @@ extension VariableModel { let staticSpace = isStatic ? "\(String.static) " : "" + let trackGetter = shouldTrackGetter(force: arguments.enableGetterHistory, context: context) + switch storageKind { case .stored(let needSetCount): let setCallCountVarDecl = needSetCount ? """ \(1.tab)\(acl)\(staticSpace)\(privateSetSpace)var \(underlyingSetCallCount) = 0 """ : "" + if trackGetter { + // Counting computed accessor over `_name`; `set` is kept so the property stays stubbable. + let getCallCountVar = "\(name)\(String.getCallCountSuffix)" + let backing = backingName(force: arguments.enableGetterHistory, context: context) + let getCallCountVarDecl = "\(1.tab)\(acl)\(staticSpace)\(privateSetSpace)var \(getCallCountVar) = 0" + let setIncrement = needSetCount ? "; \(underlyingSetCallCount) += 1" : "" + return """ + + \(setCallCountVarDecl) + \(getCallCountVarDecl) + \(1.tab)\(propertyWrapper)\(staticSpace)private var \(backing): \(underlyingType)\(assignVal) + \(1.tab)\(acl)\(staticSpace)\(overrideStr)\(modifierTypeStr)var \(name): \(type.typeName) { + \(2.tab)get { \(getCallCountVar) += 1; return \(backing) } + \(2.tab)set { \(backing) = newValue\(setIncrement) } + \(1.tab)} + """ + } + var accessorBlockItems: [String] = [] if needSetCount { let didSetBlock = """ diff --git a/Sources/MockoloFramework/Utils/StringExtensions.swift b/Sources/MockoloFramework/Utils/StringExtensions.swift index 5a0025b6..54c72ed4 100644 --- a/Sources/MockoloFramework/Utils/StringExtensions.swift +++ b/Sources/MockoloFramework/Utils/StringExtensions.swift @@ -88,6 +88,7 @@ extension String { static let rxColon = "rx:" static let varColon = "var:" static let historyColon = "history:" + static let getterColon = "getter:" static let modifiersColon = "modifiers:" static let overrideColon = "override:" static let `typealias` = "typealias" @@ -95,6 +96,7 @@ extension String { static let subjectSuffix = "Subject" static let underlyingVarPrefix = "_" static let setCallCountSuffix = "SetCallCount" + static let getCallCountSuffix = "GetCallCount" static let setHandlerSuffix = "SetHandler" static let setStateSuffix = "SetState" static let callCountSuffix = "CallCount" @@ -103,6 +105,7 @@ extension String { static let `escaping` = "@escaping" static let autoclosure = "@autoclosure" static let name = "name" + static let all = "all" static let sendable = "Sendable" static let error = "Error" static let mainActor = "MainActor" diff --git a/Tests/MockoloTestCase.swift b/Tests/MockoloTestCase.swift index 63e254ad..dbba60d2 100644 --- a/Tests/MockoloTestCase.swift +++ b/Tests/MockoloTestCase.swift @@ -54,7 +54,7 @@ class MockoloTestCase: XCTestCase { } } - func verify(srcContent: String, mockContent: String? = nil, dstContent: String, header: String = "", declType: FindTargetDeclType = .protocolType, useTemplateFunc: Bool = false, testableImports: [String] = [], allowSetCallCount: Bool = false, mockFinal: Bool = false, enableFuncArgsHistory: Bool = false, dstFilePath: String? = nil, concurrencyLimit: Int? = 1, disableCombineDefaultValues: Bool = false, file: StaticString = #filePath, line: UInt = #line) { + func verify(srcContent: String, mockContent: String? = nil, dstContent: String, header: String = "", declType: FindTargetDeclType = .protocolType, useTemplateFunc: Bool = false, testableImports: [String] = [], allowSetCallCount: Bool = false, mockFinal: Bool = false, enableFuncArgsHistory: Bool = false, enableGetterHistory: Bool = false, dstFilePath: String? = nil, concurrencyLimit: Int? = 1, disableCombineDefaultValues: Bool = false, file: StaticString = #filePath, line: UInt = #line) { let dstFilePath = dstFilePath ?? defaultDstFilePath var mockList: [String]? if let mock = mockContent { @@ -63,10 +63,10 @@ class MockoloTestCase: XCTestCase { } mockList?.append(mock) } - try? verify(srcContents: [srcContent], mockContents: mockList, dstContent: dstContent, header: header, declType: declType, useTemplateFunc: useTemplateFunc, testableImports: testableImports, allowSetCallCount: allowSetCallCount, mockFinal: mockFinal, enableFuncArgsHistory: enableFuncArgsHistory, dstFilePath: dstFilePath, concurrencyLimit: concurrencyLimit, disableCombineDefaultValues: disableCombineDefaultValues, file: file, line: line) + try? verify(srcContents: [srcContent], mockContents: mockList, dstContent: dstContent, header: header, declType: declType, useTemplateFunc: useTemplateFunc, testableImports: testableImports, allowSetCallCount: allowSetCallCount, mockFinal: mockFinal, enableFuncArgsHistory: enableFuncArgsHistory, enableGetterHistory: enableGetterHistory, dstFilePath: dstFilePath, concurrencyLimit: concurrencyLimit, disableCombineDefaultValues: disableCombineDefaultValues, file: file, line: line) } - - func verify(srcContents: [String], mockContent: String? = nil, dstContent: String, header: String = "", declType: FindTargetDeclType = .protocolType, useTemplateFunc: Bool = false, testableImports: [String] = [], allowSetCallCount: Bool = false, mockFinal: Bool = false, enableFuncArgsHistory: Bool = false, dstFilePath: String? = nil, concurrencyLimit: Int? = 1, disableCombineDefaultValues: Bool = false, file: StaticString = #filePath, line: UInt = #line) { + + func verify(srcContents: [String], mockContent: String? = nil, dstContent: String, header: String = "", declType: FindTargetDeclType = .protocolType, useTemplateFunc: Bool = false, testableImports: [String] = [], allowSetCallCount: Bool = false, mockFinal: Bool = false, enableFuncArgsHistory: Bool = false, enableGetterHistory: Bool = false, dstFilePath: String? = nil, concurrencyLimit: Int? = 1, disableCombineDefaultValues: Bool = false, file: StaticString = #filePath, line: UInt = #line) { let dstFilePath = dstFilePath ?? defaultDstFilePath var mockList: [String]? if let mock = mockContent { @@ -75,10 +75,10 @@ class MockoloTestCase: XCTestCase { } mockList?.append(mock) } - try? verify(srcContents: srcContents, mockContents: mockList, dstContent: dstContent, header: header, declType: declType, useTemplateFunc: useTemplateFunc, testableImports: testableImports, allowSetCallCount: allowSetCallCount, mockFinal: mockFinal, enableFuncArgsHistory: enableFuncArgsHistory, dstFilePath: dstFilePath, concurrencyLimit: concurrencyLimit, disableCombineDefaultValues: disableCombineDefaultValues, file: file, line: line) + try? verify(srcContents: srcContents, mockContents: mockList, dstContent: dstContent, header: header, declType: declType, useTemplateFunc: useTemplateFunc, testableImports: testableImports, allowSetCallCount: allowSetCallCount, mockFinal: mockFinal, enableFuncArgsHistory: enableFuncArgsHistory, enableGetterHistory: enableGetterHistory, dstFilePath: dstFilePath, concurrencyLimit: concurrencyLimit, disableCombineDefaultValues: disableCombineDefaultValues, file: file, line: line) } - - func verifyThrows(srcContent: String, mockContent: String? = nil, dstContent: String, header: String = "", declType: FindTargetDeclType = .protocolType, useTemplateFunc: Bool = false, testableImports: [String] = [], allowSetCallCount: Bool = false, mockFinal: Bool = false, enableFuncArgsHistory: Bool = false, dstFilePath: String? = nil, concurrencyLimit: Int? = 1, disableCombineDefaultValues: Bool = false, errorHandler: (Error) -> Void = { _ in }, file: StaticString = #filePath, line: UInt = #line) { + + func verifyThrows(srcContent: String, mockContent: String? = nil, dstContent: String, header: String = "", declType: FindTargetDeclType = .protocolType, useTemplateFunc: Bool = false, testableImports: [String] = [], allowSetCallCount: Bool = false, mockFinal: Bool = false, enableFuncArgsHistory: Bool = false, enableGetterHistory: Bool = false, dstFilePath: String? = nil, concurrencyLimit: Int? = 1, disableCombineDefaultValues: Bool = false, errorHandler: (Error) -> Void = { _ in }, file: StaticString = #filePath, line: UInt = #line) { let dstFilePath = dstFilePath ?? defaultDstFilePath var mockList: [String]? if let mock = mockContent { @@ -88,13 +88,13 @@ class MockoloTestCase: XCTestCase { mockList?.append(mock) } XCTAssertThrowsError( - try verify(srcContents: [srcContent], mockContents: mockList, dstContent: dstContent, header: header, declType: declType, useTemplateFunc: useTemplateFunc, testableImports: testableImports, allowSetCallCount: allowSetCallCount, mockFinal: mockFinal, enableFuncArgsHistory: enableFuncArgsHistory, dstFilePath: dstFilePath, concurrencyLimit: concurrencyLimit, disableCombineDefaultValues: disableCombineDefaultValues, file: file, line: line), + try verify(srcContents: [srcContent], mockContents: mockList, dstContent: dstContent, header: header, declType: declType, useTemplateFunc: useTemplateFunc, testableImports: testableImports, allowSetCallCount: allowSetCallCount, mockFinal: mockFinal, enableFuncArgsHistory: enableFuncArgsHistory, enableGetterHistory: enableGetterHistory, dstFilePath: dstFilePath, concurrencyLimit: concurrencyLimit, disableCombineDefaultValues: disableCombineDefaultValues, file: file, line: line), "No error was thrown", errorHandler ) } - func verify(srcContents: [String], mockContents: [String]?, dstContent: String, header: String, declType: FindTargetDeclType, useTemplateFunc: Bool, testableImports: [String] = [], allowSetCallCount: Bool, mockFinal: Bool, enableFuncArgsHistory: Bool, dstFilePath: String, concurrencyLimit: Int?, disableCombineDefaultValues: Bool, file: StaticString = #filePath, line: UInt = #line) throws { + func verify(srcContents: [String], mockContents: [String]?, dstContent: String, header: String, declType: FindTargetDeclType, useTemplateFunc: Bool, testableImports: [String] = [], allowSetCallCount: Bool, mockFinal: Bool, enableFuncArgsHistory: Bool, enableGetterHistory: Bool = false, dstFilePath: String, concurrencyLimit: Int?, disableCombineDefaultValues: Bool, file: StaticString = #filePath, line: UInt = #line) throws { var index = 0 srcFilePathsCount = srcContents.count mockFilePathsCount = mockContents?.count ?? 0 @@ -135,6 +135,7 @@ class MockoloTestCase: XCTestCase { useTemplateFunc: useTemplateFunc, allowSetCallCount: allowSetCallCount, enableFuncArgsHistory: enableFuncArgsHistory, + enableGetterHistory: enableGetterHistory, disableCombineDefaultValues: disableCombineDefaultValues, mockFinal: mockFinal, testableImports: testableImports, diff --git a/Tests/TestGetterHistory/FixtureGetterHistory.swift b/Tests/TestGetterHistory/FixtureGetterHistory.swift new file mode 100644 index 00000000..4bfc2cc8 --- /dev/null +++ b/Tests/TestGetterHistory/FixtureGetterHistory.swift @@ -0,0 +1,458 @@ +import Combine +import MockoloFramework + +// Support type for non-defaultable / optional getter-tracking fixtures so the generated +// `expected` mocks compile as part of the test target. +struct CustomThing {} + +@Fixture enum getterHistorySpecific { + /// @mockable(getter: state = true) + public protocol GHSpecific { + var state: Int { get } + var token: String { get } + } + + @Fixture enum expected { + public class GHSpecificMock: GHSpecific { + public init() { } + public init(state: Int = 0, token: String = "") { + self._state = state + self.token = token + } + public private(set) var stateGetCallCount = 0 + private var _state: Int = 0 + public var state: Int { + get { stateGetCallCount += 1; return _state } + set { _state = newValue } + } + public var token: String = "" + } + } +} + +@Fixture enum getterHistoryAllExcept { + /// @mockable(getter: all = true; token = false) + public protocol GHAllExcept { + var state: Int { get } + var token: String { get } + } + + @Fixture enum expected { + public class GHAllExceptMock: GHAllExcept { + public init() { } + public init(state: Int = 0, token: String = "") { + self._state = state + self.token = token + } + public private(set) var stateGetCallCount = 0 + private var _state: Int = 0 + public var state: Int { + get { stateGetCallCount += 1; return _state } + set { _state = newValue } + } + public var token: String = "" + } + } +} + +@Fixture enum getterHistoryGetSet { + /// @mockable(getter: session = true) + public protocol GHGetSet { + var session: String { get set } + } + + @Fixture enum expected { + public class GHGetSetMock: GHGetSet { + public init() { } + public init(session: String = "") { + self._session = session + } + public private(set) var sessionSetCallCount = 0 + public private(set) var sessionGetCallCount = 0 + private var _session: String = "" + public var session: String { + get { sessionGetCallCount += 1; return _session } + set { _session = newValue; sessionSetCallCount += 1 } + } + } + } +} + +@Fixture enum getterHistoryNonDefault { + /// @mockable(getter: item = true) + public protocol GHNonDefault { + var item: CustomThing { get } + } + + @Fixture enum expected { + public class GHNonDefaultMock: GHNonDefault { + public init() { } + public init(item: CustomThing) { + self._item = item + } + public private(set) var itemGetCallCount = 0 + private var _item: CustomThing! + public var item: CustomThing { + get { itemGetCallCount += 1; return _item } + set { _item = newValue } + } + } + } +} + +@Fixture enum getterHistoryOptionalGetOnly { + /// @mockable(getter: item = true) + public protocol GHOptional { + var item: CustomThing? { get } + } + + @Fixture enum expected { + public class GHOptionalMock: GHOptional { + public init() { } + public init(item: CustomThing? = nil) { + self._item = item + } + public private(set) var itemGetCallCount = 0 + private var _item: CustomThing? = nil + public var item: CustomThing? { + get { itemGetCallCount += 1; return _item } + set { _item = newValue } + } + } + } +} + +@Fixture enum getterHistoryStatic { + /// @mockable(getter: token = true) + public protocol GHStatic { + static var token: String { get } + } + + @Fixture enum expected { + public class GHStaticMock: GHStatic { + public init() { } + public static private(set) var tokenGetCallCount = 0 + static private var _token: String = "" + public static var token: String { + get { tokenGetCallCount += 1; return _token } + set { _token = newValue } + } + } + } +} + +// A tracked sibling alongside an explicit `plain = false` opt-out: the opt-out get-set property +// must keep its existing `var = default { didSet }` shape (no churn on the settable path). +@Fixture enum getterHistoryGetSetOptOut { + /// @mockable(getter: tracked = true; plain = false) + public protocol GHGetSetOptOut { + var tracked: Int { get } + var plain: Int { get set } + } + + @Fixture enum expected { + public class GHGetSetOptOutMock: GHGetSetOptOut { + public init() { } + public init(tracked: Int = 0, plain: Int = 0) { + self._tracked = tracked + self.plain = plain + } + public private(set) var trackedGetCallCount = 0 + private var _tracked: Int = 0 + public var tracked: Int { + get { trackedGetCallCount += 1; return _tracked } + set { _tracked = newValue } + } + public private(set) var plainSetCallCount = 0 + public var plain: Int = 0 { didSet { plainSetCallCount += 1 } } + } + } +} + +// No annotation; tracking is forced by the `--enable-getter-history` global flag. +@Fixture enum getterHistoryGlobalFlag { + /// @mockable + public protocol GHGlobalFlag { + var state: Int { get } + var name: String { get set } + } + + @Fixture enum expected { + public class GHGlobalFlagMock: GHGlobalFlag { + public init() { } + public init(state: Int = 0, name: String = "") { + self._state = state + self._name = name + } + public private(set) var stateGetCallCount = 0 + private var _state: Int = 0 + public var state: Int { + get { stateGetCallCount += 1; return _state } + set { _state = newValue } + } + public private(set) var nameSetCallCount = 0 + public private(set) var nameGetCallCount = 0 + private var _name: String = "" + public var name: String { + get { nameGetCallCount += 1; return _name } + set { _name = newValue; nameSetCallCount += 1 } + } + } + } +} + +// Global flag on, but an explicit `token = false` opts that property out — proves explicit wins over the flag. +@Fixture enum getterHistoryGlobalFlagOptOut { + /// @mockable(getter: token = false) + public protocol GHGlobalOptOut { + var state: Int { get } + var token: String { get } + } + + @Fixture enum expected { + public class GHGlobalOptOutMock: GHGlobalOptOut { + public init() { } + public init(state: Int = 0, token: String = "") { + self._state = state + self.token = token + } + public private(set) var stateGetCallCount = 0 + private var _state: Int = 0 + public var state: Int { + get { stateGetCallCount += 1; return _state } + set { _state = newValue } + } + public var token: String = "" + } + } +} + +// `--allow-set-call-count` composes: both counters drop `private(set)`. +@Fixture enum getterHistoryAllowSetCallCount { + /// @mockable(getter: session = true) + public protocol GHAllowSet { + var session: String { get set } + } + + @Fixture enum expected { + public class GHAllowSetMock: GHAllowSet { + public init() { } + public init(session: String = "") { + self._session = session + } + public var sessionSetCallCount = 0 + public var sessionGetCallCount = 0 + private var _session: String = "" + public var session: String { + get { sessionGetCallCount += 1; return _session } + set { _session = newValue; sessionSetCallCount += 1 } + } + } + } +} + +// Class mock (`--mock-all`) + flag: getter tracking is a no-op (protocol-only guard). +@Fixture enum getterHistoryClassMockNoop { + /// @mockable + public class GHClass { + public var state: Int = 0 + public init() {} + } + + @Fixture enum expected { + public class GHClassMock: GHClass { + override public init() { + super.init() + } + public private(set) var stateSetCallCount = 0 + public override var state: Int { didSet { stateSetCallCount += 1 } } + } + } +} + +// Intercepted publisher/observable/wrapper/weak/dynamic props are never tracked; only `tracked` converts. +@Fixture(imports: ["Combine"]) enum getterHistoryExcludesCombineRxWrapperWeak { + /// @mockable(getter: all = true; combine: pubPublisher = @Published wrapped; rx: streamObs = PublishSubject; modifiers: weakRef = weak; dynRef = dynamic) + public protocol GHExcludes { + var wrapped: Int { get set } + var pubPublisher: AnyPublisher { get } + var streamObs: Observable { get } + var weakRef: AnyObject? { get } + var dynRef: AnyObject? { get } + var tracked: Int { get } + } + + @Fixture(imports: ["Combine"]) enum expected { + public class GHExcludesMock: GHExcludes { + public init() { } + public init(wrapped: Int = 0, streamObs: Observable = PublishSubject(), weakRef: AnyObject? = nil, dynRef: AnyObject? = nil, tracked: Int = 0) { + self.wrapped = wrapped + self.streamObs = streamObs + self.weakRef = weakRef + self.dynRef = dynRef + self._tracked = tracked + } + public private(set) var wrappedSetCallCount = 0 + @Published public var wrapped: Int = 0 { didSet { wrappedSetCallCount += 1 } } + public var pubPublisher: AnyPublisher { return self.$wrapped.setFailureType(to: Never.self).eraseToAnyPublisher() } + public private(set) var streamObsSubjectSetCallCount = 0 + var _streamObs: Observable? { didSet { streamObsSubjectSetCallCount += 1 } } + public var streamObsSubject = PublishSubject() { didSet { streamObsSubjectSetCallCount += 1 } } + public var streamObs: Observable { + get { return _streamObs ?? streamObsSubject } + set { if let val = newValue as? PublishSubject { streamObsSubject = val } else { _streamObs = newValue } } + } + public weak var weakRef: AnyObject? = nil + public dynamic var dynRef: AnyObject? = nil + public private(set) var trackedGetCallCount = 0 + private var _tracked: Int = 0 + public var tracked: Int { + get { trackedGetCallCount += 1; return _tracked } + set { _tracked = newValue } + } + } + } +} + +// A direct `BehaviorSubject<…>` is NOT intercepted by the Rx template, so it stays eligible and IS tracked. +@Fixture enum getterHistoryRxSubjectEligible { + /// @mockable(getter: subj = true) + public protocol GHRxSubject { + var subj: BehaviorSubject { get } + } + + @Fixture enum expected { + public class GHRxSubjectMock: GHRxSubject { + public init() { } + public init(subj: BehaviorSubject) { + self._subj = subj + } + public private(set) var subjGetCallCount = 0 + private var _subj: BehaviorSubject! + public var subj: BehaviorSubject { + get { subjGetCallCount += 1; return _subj } + set { _subj = newValue } + } + } + } +} + +// Actor mock: the counter is a plain (actor-isolated) property — no MockoloMutex. +@Fixture enum getterHistoryActor { + /// @mockable + public protocol GHActor: Actor { + var state: Int { get } + } + + @Fixture enum expected { + public actor GHActorMock: GHActor { + public init() { } + public init(state: Int = 0) { + self._state = state + } + public private(set) var stateGetCallCount = 0 + private var _state: Int = 0 + public var state: Int { + get { stateGetCallCount += 1; return _state } + set { _state = newValue } + } + } + } +} + +// Re-ingesting an already-generated mock that contains `valueGetCallCount`: the merger must key it +// back to `value` (not `valueGet`) so the child's inherited `value` dedups cleanly (no redeclaration). +@Fixture enum getterHistoryProcessedMockMerge { + public protocol GHParent { + var value: Int { get } + } + /// @mockable + public protocol GHChild: GHParent { + var value: Int { get } + } + + @Fixture enum parent { + public class GHParentMock: GHParent { + public init() { } + public private(set) var valueGetCallCount = 0 + private var _value: Int = 0 + public var value: Int { + get { valueGetCallCount += 1; return _value } + set { _value = newValue } + } + } + } + + typealias GHParentMock = parent.GHParentMock + + @Fixture enum expected { + public class GHChildMock: GHChild { + public init() { } + public init(value: Int = 0) { + self._value = value + } + public private(set) var valueGetCallCount = 0 + private var _value: Int = 0 + public var value: Int { + get { valueGetCallCount += 1; return _value } + set { _value = newValue } + } + } + } +} + +// A tracked optional-closure get-only prop emits `_handler`; an explicit `init(handler:)` whose +// param is not an init candidate would also emit one. Confirms exactly one `_handler` (dedup). +@Fixture enum getterHistoryOptionalClosureInitCollision { + /// @mockable(getter: handler = true) + public protocol GHClosure { + var handler: (() -> Void)? { get } + init(handler: (() -> Void)?) + } + + @Fixture enum expected { + public class GHClosureMock: GHClosure { + public init() { } + required public init(handler: (() -> Void)? = nil) { + self._handler = handler + } + public private(set) var handlerGetCallCount = 0 + private var _handler: (() -> Void)? = nil + public var handler: (() -> Void)? { + get { handlerGetCallCount += 1; return _handler } + set { _handler = newValue } + } + } + } +} + +// Existential `any` and namespaced types render valid backing stores when tracked. +@Fixture enum getterHistoryEdgeTypes { + /// @mockable(getter: all = true) + public protocol GHTypes { + var anyVal: any Sequence { get } + var nsVal: Swift.Int { get } + } + + @Fixture enum expected { + public class GHTypesMock: GHTypes { + public init() { } + public init(anyVal: any Sequence, nsVal: Swift.Int) { + self._anyVal = anyVal + self._nsVal = nsVal + } + public private(set) var anyValGetCallCount = 0 + private var _anyVal: (any Sequence)! + public var anyVal: any Sequence { + get { anyValGetCallCount += 1; return _anyVal } + set { _anyVal = newValue } + } + public private(set) var nsValGetCallCount = 0 + private var _nsVal: Swift.Int! + public var nsVal: Swift.Int { + get { nsValGetCallCount += 1; return _nsVal } + set { _nsVal = newValue } + } + } + } +} diff --git a/Tests/TestGetterHistory/FixtureGetterHistorySendable.swift b/Tests/TestGetterHistory/FixtureGetterHistorySendable.swift new file mode 100644 index 00000000..cf915b3f --- /dev/null +++ b/Tests/TestGetterHistory/FixtureGetterHistorySendable.swift @@ -0,0 +1,28 @@ +#if compiler(>=6.0) +import MockoloFramework + +// `@unchecked Sendable` mock: the getter counter is a plain, unsynchronized property — it follows +// the existing `SetCallCount` model, NOT the method `MockoloMutex` model. This locks that behavior. +@Fixture enum getterHistorySendable { + /// @mockable + public protocol GHSendable: Sendable { + var state: Int { get } + } + + @Fixture(includesConcurrencyHelpers: true) + enum expected { + public final class GHSendableMock: GHSendable, @unchecked Sendable { + public init() { } + public init(state: Int = 0) { + self._state = state + } + public private(set) var stateGetCallCount = 0 + private var _state: Int = 0 + public var state: Int { + get { stateGetCallCount += 1; return _state } + set { _state = newValue } + } + } + } +} +#endif diff --git a/Tests/TestGetterHistory/GetterHistoryTests.swift b/Tests/TestGetterHistory/GetterHistoryTests.swift new file mode 100644 index 00000000..5018ad1a --- /dev/null +++ b/Tests/TestGetterHistory/GetterHistoryTests.swift @@ -0,0 +1,157 @@ +import XCTest + +class GetterHistoryTests: MockoloTestCase { + + // MARK: - Annotation-driven (string-diff) cases + + func testGetterHistorySpecific() { + verify(srcContent: getterHistorySpecific._source, + dstContent: getterHistorySpecific.expected._source) + } + + func testGetterHistoryAllExcept() { + verify(srcContent: getterHistoryAllExcept._source, + dstContent: getterHistoryAllExcept.expected._source) + } + + func testGetterHistoryGetSet() { + verify(srcContent: getterHistoryGetSet._source, + dstContent: getterHistoryGetSet.expected._source) + } + + func testGetterHistoryNonDefault() { + verify(srcContent: getterHistoryNonDefault._source, + dstContent: getterHistoryNonDefault.expected._source) + } + + func testGetterHistoryOptionalGetOnly() { + verify(srcContent: getterHistoryOptionalGetOnly._source, + dstContent: getterHistoryOptionalGetOnly.expected._source) + } + + func testGetterHistoryStatic() { + verify(srcContent: getterHistoryStatic._source, + dstContent: getterHistoryStatic.expected._source) + } + + func testGetterHistoryGetSetOptOut() { + verify(srcContent: getterHistoryGetSetOptOut._source, + dstContent: getterHistoryGetSetOptOut.expected._source) + } + + func testGetterHistoryExcludesCombineRxWrapperWeak() { + verify(srcContent: getterHistoryExcludesCombineRxWrapperWeak._source, + dstContent: getterHistoryExcludesCombineRxWrapperWeak.expected._source) + } + + func testGetterHistoryRxSubjectEligible() { + verify(srcContent: getterHistoryRxSubjectEligible._source, + dstContent: getterHistoryRxSubjectEligible.expected._source) + } + + // MARK: - Global flag (`--enable-getter-history`) cases + + func testGetterHistoryGlobalFlag() { + verify(srcContent: getterHistoryGlobalFlag._source, + dstContent: getterHistoryGlobalFlag.expected._source, + enableGetterHistory: true) + } + + func testGetterHistoryGlobalFlagOptOutWins() { + verify(srcContent: getterHistoryGlobalFlagOptOut._source, + dstContent: getterHistoryGlobalFlagOptOut.expected._source, + enableGetterHistory: true) + } + + func testGetterHistoryAllowSetCallCount() { + verify(srcContent: getterHistoryAllowSetCallCount._source, + dstContent: getterHistoryAllowSetCallCount.expected._source, + allowSetCallCount: true) + } + + func testGetterHistoryActorWithFlag() { + verify(srcContent: getterHistoryActor._source, + dstContent: getterHistoryActor.expected._source, + enableGetterHistory: true) + } + + // MARK: - No-op / guard cases + + func testGetterHistoryClassMockNoop() { + verify(srcContent: getterHistoryClassMockNoop._source, + dstContent: getterHistoryClassMockNoop.expected._source, + declType: .classType, + enableGetterHistory: true) + } + + func testGetterHistoryProcessedMockMerge() { + verify(srcContent: getterHistoryProcessedMockMerge._source, + mockContent: getterHistoryProcessedMockMerge.parent._source, + dstContent: getterHistoryProcessedMockMerge.expected._source, + enableGetterHistory: true) + } + + func testGetterHistoryOptionalClosureInitCollision() { + verify(srcContent: getterHistoryOptionalClosureInitCollision._source, + dstContent: getterHistoryOptionalClosureInitCollision.expected._source) + } + + func testGetterHistoryEdgeTypes() { + verify(srcContent: getterHistoryEdgeTypes._source, + dstContent: getterHistoryEdgeTypes.expected._source) + } + + // MARK: - Runtime behavior + // + // These instantiate the compiled `expected` fixture mocks directly. That's sound because the + // matching string-diff tests above prove `expected == Mockolo's generated output`, so the + // runtime behavior of the fixture mock is the runtime behavior of the generated mock. + + func testRuntimeGetterIncrementsOnRead() { + let mock = getterHistoryGetSet.expected.GHGetSetMock() + XCTAssertEqual(mock.sessionGetCallCount, 0) + for expected in 1...3 { + _ = mock.session + XCTAssertEqual(mock.sessionGetCallCount, expected) + } + // Reads do not affect the setter counter. + XCTAssertEqual(mock.sessionSetCallCount, 0) + } + + func testRuntimeSetterIncrementsIndependently() { + let mock = getterHistoryGetSet.expected.GHGetSetMock() + mock.session = "a" + mock.session = "b" + XCTAssertEqual(mock.sessionSetCallCount, 2) + // Writes do not affect the getter counter. + XCTAssertEqual(mock.sessionGetCallCount, 0) + } + + func testRuntimeInitDoesNotBumpCounters() { + // Init writes the `_session` backing directly, so neither counter moves during initialization. + let mock = getterHistoryGetSet.expected.GHGetSetMock(session: "seed") + XCTAssertEqual(mock.sessionGetCallCount, 0) + XCTAssertEqual(mock.sessionSetCallCount, 0) + XCTAssertEqual(mock.session, "seed") + XCTAssertEqual(mock.sessionGetCallCount, 1) + } + + func testRuntimeGetOnlyIsStillStubbable() { + // A tracked get-only property keeps its setter, so tests can stub it. + let mock = getterHistorySpecific.expected.GHSpecificMock() + mock.state = 42 + XCTAssertEqual(mock.stateGetCallCount, 0) + XCTAssertEqual(mock.state, 42) + XCTAssertEqual(mock.stateGetCallCount, 1) + } +} + +#if compiler(>=6.0) +extension GetterHistoryTests { + func testGetterHistorySendable() { + verify(srcContent: getterHistorySendable._source, + dstContent: getterHistorySendable.expected._source, + enableGetterHistory: true) + } +} +#endif From 78018aa9dcd0140462dca2c3a67c1745ffe6565c Mon Sep 17 00:00:00 2001 From: Peter Krassay Date: Sat, 6 Jun 2026 15:34:30 -0600 Subject: [PATCH 2/4] Track getter call counts for async/throwing protocol getters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend getter call-count tracking to `{ get async }` / `{ get throws }` / `{ get async throws }` getters, which render via the handler-backed `.computed` path (no backing store). `shouldTrackGetter` now also admits `.computed` getters; the counter is declared alongside the handler and bumped as the first statement of the getter body — before any `await`/`throw`, so a read that throws is still counted: private(set) var valueGetCallCount = 0 var valueHandler: (() async -> Int)? var value: Int { get async { valueGetCallCount += 1 ... } } `backingName` still returns the plain `underlyingName` for `.computed` props, so no `_name` backing is invented and init/dedup are untouched (an init param for such a property continues to set its handler). When tracking is off the `.computed` branch is byte-for-byte unchanged. Adds async/throws/async-throws fixtures plus runtime tests asserting the counter increments per `await`ed read and that a throwing read is counted. Full suite green (174 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- .../Models/VariableModel.swift | 3 +- .../Templates/VariableTemplate.swift | 17 +++++- .../FixtureGetterHistoryAsync.swift | 58 +++++++++++++++++++ .../GetterHistoryTests.swift | 25 ++++++++ 5 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 Tests/TestGetterHistory/FixtureGetterHistoryAsync.swift diff --git a/README.md b/README.md index daa6214b..602ab458 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,7 @@ public class FooMock: Foo { Use `all = true` to opt every property in, and `name = false` to opt one back out. The `--enable-getter-history` flag enables tracking project-wide; an explicit `name = false` (or `all = false`) still wins over it. This mirrors SwiftyMocky's `Verify(mock, .once, .someProperty)`, which maps onto `mock.somePropertyGetCallCount`. -> NOTE: Protocol mocks only (a no-op for `--mock-all` class mocks). Combine/Rx/`@Published`-intercepted properties (`AnyPublisher`, `Observable`, `rx:`/`combine:`-annotated) and `weak`/`dynamic` properties are never tracked, since they cannot be backed by a computed accessor. +> NOTE: Protocol mocks only (a no-op for `--mock-all` class mocks). Combine/Rx/`@Published`-intercepted properties (`AnyPublisher`, `Observable`, `rx:`/`combine:`-annotated) and `weak`/`dynamic` properties are never tracked, since they cannot be backed by a computed accessor. Async/throwing getters are supported. ### Combine's AnyPublisher diff --git a/Sources/MockoloFramework/Models/VariableModel.swift b/Sources/MockoloFramework/Models/VariableModel.swift index 88ab889d..520856eb 100644 --- a/Sources/MockoloFramework/Models/VariableModel.swift +++ b/Sources/MockoloFramework/Models/VariableModel.swift @@ -159,11 +159,10 @@ extension VariableModel { return type?.typeName.range(of: String.observableLeftAngleBracket) != nil } - // Protocol-mock stored getters only; intercepted, property-wrapped, weak/dynamic and processed props are excluded. + // Protocol-mock getters only; intercepted, property-wrapped, weak/dynamic and processed props are excluded. func shouldTrackGetter(force: Bool, context: RenderContext) -> Bool { guard !processed, context.annotatedTypeKind == .protocol, - case .stored = storageKind, !isCombineVariable, !isRxVariable, propertyWrapper == nil, diff --git a/Sources/MockoloFramework/Templates/VariableTemplate.swift b/Sources/MockoloFramework/Templates/VariableTemplate.swift index f749a0c2..5e5392f3 100644 --- a/Sources/MockoloFramework/Templates/VariableTemplate.swift +++ b/Sources/MockoloFramework/Templates/VariableTemplate.swift @@ -137,12 +137,25 @@ extension VariableModel { ), arguments: arguments) ?? "") .addingIndent(1) + // Async/throwing getters have no backing store: bump the counter first in the getter body + // (before any `await`/`throw`, so a read that throws is still counted). + let getCallCountVarDecl: String + let getIncrement: String + if trackGetter { + let getCallCountVar = "\(name)\(String.getCallCountSuffix)" + getCallCountVarDecl = "\(1.tab)\(acl)\(staticSpace)\(privateSetSpace)var \(getCallCountVar) = 0\n" + getIncrement = "\(3.tab)\(getCallCountVar) += 1\n" + } else { + getCallCountVarDecl = "" + getIncrement = "" + } + return """ - \(1.tab)\(acl)\(staticSpace)var \(name)\(String.handlerSuffix): (() \(effects.applyTemplate())-> \(type.typeName))? + \(getCallCountVarDecl)\(1.tab)\(acl)\(staticSpace)var \(name)\(String.handlerSuffix): (() \(effects.applyTemplate())-> \(type.typeName))? \(1.tab)\(acl)\(staticSpace)\(overrideStr)\(modifierTypeStr)var \(name): \(type.typeName) { \(2.tab)get \(effects.applyTemplate()){ - \(body) + \(getIncrement)\(body) \(2.tab)} \(1.tab)} """ diff --git a/Tests/TestGetterHistory/FixtureGetterHistoryAsync.swift b/Tests/TestGetterHistory/FixtureGetterHistoryAsync.swift new file mode 100644 index 00000000..d165e06a --- /dev/null +++ b/Tests/TestGetterHistory/FixtureGetterHistoryAsync.swift @@ -0,0 +1,58 @@ +#if compiler(>=6.0) +import MockoloFramework + +// Async/throwing getters use the handler-backed `.computed` path (no backing store): the counter +// is declared and bumped as the first statement of the getter body, before any `await`/`throw`. +@Fixture enum getterHistoryAsync { + /// @mockable(getter: all = true) + public protocol GHAsync { + var value: Int { get async } + var name: String { get throws } + var session: Int { get async throws } + } + + @Fixture enum expected { + public class GHAsyncMock: GHAsync { + public init() { } + public init(value: Int = 0, name: String = "", session: Int = 0) { + self.valueHandler = { value } + self.nameHandler = { name } + self.sessionHandler = { session } + } + public private(set) var valueGetCallCount = 0 + public var valueHandler: (() async -> Int)? + public var value: Int { + get async { + valueGetCallCount += 1 + if let valueHandler = valueHandler { + return await valueHandler() + } + return 0 + } + } + public private(set) var nameGetCallCount = 0 + public var nameHandler: (() throws -> String)? + public var name: String { + get throws { + nameGetCallCount += 1 + if let nameHandler = nameHandler { + return try nameHandler() + } + return "" + } + } + public private(set) var sessionGetCallCount = 0 + public var sessionHandler: (() async throws -> Int)? + public var session: Int { + get async throws { + sessionGetCallCount += 1 + if let sessionHandler = sessionHandler { + return try await sessionHandler() + } + return 0 + } + } + } + } +} +#endif diff --git a/Tests/TestGetterHistory/GetterHistoryTests.swift b/Tests/TestGetterHistory/GetterHistoryTests.swift index 5018ad1a..7f2a1f2b 100644 --- a/Tests/TestGetterHistory/GetterHistoryTests.swift +++ b/Tests/TestGetterHistory/GetterHistoryTests.swift @@ -153,5 +153,30 @@ extension GetterHistoryTests { dstContent: getterHistorySendable.expected._source, enableGetterHistory: true) } + + func testGetterHistoryAsync() { + verify(srcContent: getterHistoryAsync._source, + dstContent: getterHistoryAsync.expected._source) + } + + func testRuntimeAsyncGetterIncrementsOnRead() async { + let mock = getterHistoryAsync.expected.GHAsyncMock() + mock.valueHandler = { 7 } + XCTAssertEqual(mock.valueGetCallCount, 0) + for expected in 1...3 { + let value = await mock.value + XCTAssertEqual(value, 7) + XCTAssertEqual(mock.valueGetCallCount, expected) + } + } + + func testRuntimeThrowingGetterCountsBeforeThrow() { + // The counter bumps before the throw, so a read that throws still counts. + struct GetterError: Error {} + let mock = getterHistoryAsync.expected.GHAsyncMock() + mock.nameHandler = { throw GetterError() } + XCTAssertThrowsError(try mock.name) + XCTAssertEqual(mock.nameGetCallCount, 1) + } } #endif From 729830de1e8a1b7442385e99224383c13535bdb8 Mon Sep 17 00:00:00 2001 From: Peter Krassay Date: Wed, 10 Jun 2026 16:54:38 -0600 Subject: [PATCH 3/4] Add getter-on-IUO fixture re-enabled by the #354 IUO rendering fix Co-Authored-By: Claude Fable 5 --- Tests/TestGetterHistory/FixtureGetterHistory.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Tests/TestGetterHistory/FixtureGetterHistory.swift b/Tests/TestGetterHistory/FixtureGetterHistory.swift index 4bfc2cc8..26cc8fb8 100644 --- a/Tests/TestGetterHistory/FixtureGetterHistory.swift +++ b/Tests/TestGetterHistory/FixtureGetterHistory.swift @@ -426,20 +426,22 @@ struct CustomThing {} } } -// Existential `any` and namespaced types render valid backing stores when tracked. +// Existential `any`, namespaced, and IUO types render valid backing stores when tracked. @Fixture enum getterHistoryEdgeTypes { /// @mockable(getter: all = true) public protocol GHTypes { var anyVal: any Sequence { get } var nsVal: Swift.Int { get } + var iuo: Int! { get } } @Fixture enum expected { public class GHTypesMock: GHTypes { public init() { } - public init(anyVal: any Sequence, nsVal: Swift.Int) { + public init(anyVal: any Sequence, nsVal: Swift.Int, iuo: Int! = nil) { self._anyVal = anyVal self._nsVal = nsVal + self._iuo = iuo } public private(set) var anyValGetCallCount = 0 private var _anyVal: (any Sequence)! @@ -453,6 +455,12 @@ struct CustomThing {} get { nsValGetCallCount += 1; return _nsVal } set { _nsVal = newValue } } + public private(set) var iuoGetCallCount = 0 + private var _iuo: Int! = nil + public var iuo: Int! { + get { iuoGetCallCount += 1; return _iuo } + set { _iuo = newValue } + } } } } From 51ae6ab44a4edd120432d33c6502db2c4d779af0 Mon Sep 17 00:00:00 2001 From: Peter Krassay Date: Wed, 10 Jun 2026 17:24:06 -0600 Subject: [PATCH 4/4] Harden getter-history coverage: all=false vs flag, PAT, inherited members Co-Authored-By: Claude Fable 5 --- README.md | 2 +- .../FixtureGetterHistory.swift | 79 +++++++++++++++++++ .../GetterHistoryTests.swift | 16 ++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 602ab458..d129a240 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,7 @@ public class FooMock: Foo { Use `all = true` to opt every property in, and `name = false` to opt one back out. The `--enable-getter-history` flag enables tracking project-wide; an explicit `name = false` (or `all = false`) still wins over it. This mirrors SwiftyMocky's `Verify(mock, .once, .someProperty)`, which maps onto `mock.somePropertyGetCallCount`. -> NOTE: Protocol mocks only (a no-op for `--mock-all` class mocks). Combine/Rx/`@Published`-intercepted properties (`AnyPublisher`, `Observable`, `rx:`/`combine:`-annotated) and `weak`/`dynamic` properties are never tracked, since they cannot be backed by a computed accessor. Async/throwing getters are supported. +> NOTE: Protocol mocks only (a no-op for `--mock-all` class mocks). Combine/Rx/`@Published`-intercepted properties (`AnyPublisher`, `Observable`, `rx:`/`combine:`-annotated) and `weak`/`dynamic` properties are never tracked, since they cannot be backed by a computed accessor. Async/throwing getters are supported. Like the existing `…SetCallCount`, the counter is a plain unsynchronized property, so concurrent reads on a `Sendable` mock carry the same data-race caveat as setter counters. ### Combine's AnyPublisher diff --git a/Tests/TestGetterHistory/FixtureGetterHistory.swift b/Tests/TestGetterHistory/FixtureGetterHistory.swift index 26cc8fb8..bf9dc902 100644 --- a/Tests/TestGetterHistory/FixtureGetterHistory.swift +++ b/Tests/TestGetterHistory/FixtureGetterHistory.swift @@ -227,6 +227,28 @@ struct CustomThing {} } } +// Global flag on, but `all = false` opts the whole protocol out — proves the protocol-wide +// opt-out wins over the flag, same as a per-name `false`. +@Fixture enum getterHistoryGlobalFlagAllOptOut { + /// @mockable(getter: all = false) + public protocol GHGlobalAllOptOut { + var state: Int { get } + var token: String { get } + } + + @Fixture enum expected { + public class GHGlobalAllOptOutMock: GHGlobalAllOptOut { + public init() { } + public init(state: Int = 0, token: String = "") { + self.state = state + self.token = token + } + public var state: Int = 0 + public var token: String = "" + } + } +} + // `--allow-set-call-count` composes: both counters drop `private(set)`. @Fixture enum getterHistoryAllowSetCallCount { /// @mockable(getter: session = true) @@ -464,3 +486,60 @@ struct CustomThing {} } } } + +// Inherited members keep the metadata of the protocol that declared them: the child's +// `getter: all = true` tracks its own property but NOT the inherited, un-annotated one — +// same per-declaring-protocol semantics as `history:`/`rx:`/`combine:`. Track inherited +// members by annotating the parent, or globally via `--enable-getter-history`. +@Fixture enum getterHistoryInheritedUntracked { + public protocol GHBase { + var value: Int { get } + } + + /// @mockable(getter: all = true) + public protocol GHDerived: GHBase { + var own: Int { get } + } + + @Fixture enum expected { + public class GHDerivedMock: GHDerived { + public init() { } + public init(value: Int = 0, own: Int = 0) { + self.value = value + self._own = own + } + public var value: Int = 0 + public private(set) var ownGetCallCount = 0 + private var _own: Int = 0 + public var own: Int { + get { ownGetCallCount += 1; return _own } + set { _own = newValue } + } + } + } +} + +// An associatedtype-typed property: the tracked backing store renders against the mock's typealias. +@Fixture enum getterHistoryAssociatedType { + /// @mockable(typealias: T = String; getter: item = true) + public protocol GHPat { + associatedtype T + var item: T { get } + } + + @Fixture enum expected { + public class GHPatMock: GHPat { + public init() { } + public init(item: T) { + self._item = item + } + public typealias T = String + public private(set) var itemGetCallCount = 0 + private var _item: T! + public var item: T { + get { itemGetCallCount += 1; return _item } + set { _item = newValue } + } + } + } +} diff --git a/Tests/TestGetterHistory/GetterHistoryTests.swift b/Tests/TestGetterHistory/GetterHistoryTests.swift index 7f2a1f2b..26f38828 100644 --- a/Tests/TestGetterHistory/GetterHistoryTests.swift +++ b/Tests/TestGetterHistory/GetterHistoryTests.swift @@ -63,6 +63,12 @@ class GetterHistoryTests: MockoloTestCase { enableGetterHistory: true) } + func testGetterHistoryGlobalFlagAllOptOutWins() { + verify(srcContent: getterHistoryGlobalFlagAllOptOut._source, + dstContent: getterHistoryGlobalFlagAllOptOut.expected._source, + enableGetterHistory: true) + } + func testGetterHistoryAllowSetCallCount() { verify(srcContent: getterHistoryAllowSetCallCount._source, dstContent: getterHistoryAllowSetCallCount.expected._source, @@ -101,6 +107,16 @@ class GetterHistoryTests: MockoloTestCase { dstContent: getterHistoryEdgeTypes.expected._source) } + func testGetterHistoryInheritedUntracked() { + verify(srcContent: getterHistoryInheritedUntracked._source, + dstContent: getterHistoryInheritedUntracked.expected._source) + } + + func testGetterHistoryAssociatedType() { + verify(srcContent: getterHistoryAssociatedType._source, + dstContent: getterHistoryAssociatedType.expected._source) + } + // MARK: - Runtime behavior // // These instantiate the compiled `expected` fixture mocks directly. That's sound because the