Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ OPTIONS:
--custom-imports <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 <exclude-imports>
Expand Down Expand Up @@ -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. 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

To generate mocks for Combine's AnyPublisher:
Expand Down
5 changes: 5 additions & 0 deletions Sources/Mockolo/Executor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -201,6 +205,7 @@ struct Executor: ParsableCommand {
useTemplateFunc: useTemplateFunc,
allowSetCallCount: allowSetCallCount,
enableFuncArgsHistory: enableArgsHistory,
enableGetterHistory: enableGetterHistory,
disableCombineDefaultValues: disableCombineDefaultValues,
mockFinal: mockFinal,
testableImports: testableImports,
Expand Down
5 changes: 4 additions & 1 deletion Sources/MockoloFramework/Models/ParsedEntity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]?
}
Expand All @@ -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
)
}

Expand Down
55 changes: 55 additions & 0 deletions Sources/MockoloFramework/Models/VariableModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?
Expand Down Expand Up @@ -52,6 +60,7 @@ final class VariableModel: Model {
offset: Int64,
rxTypes: [String: String]?,
customModifiers: [String: Modifier]?,
getterHistory: GetterHistory,
modelDescription: String?,
combineType: CombineType?,
processed: Bool) {
Expand All @@ -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
Expand Down Expand Up @@ -132,3 +142,48 @@ 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 getters only; intercepted, property-wrapped, weak/dynamic and processed props are excluded.
func shouldTrackGetter(force: Bool, context: RenderContext) -> Bool {
guard !processed,
context.annotatedTypeKind == .protocol,
!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)"
}
}
4 changes: 3 additions & 1 deletion Sources/MockoloFramework/Operations/Generator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public func generate(sourceDirs: [String],
useTemplateFunc: Bool,
allowSetCallCount: Bool,
enableFuncArgsHistory: Bool,
enableGetterHistory: Bool,
disableCombineDefaultValues: Bool,
mockFinal: Bool,
testableImports: [String],
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ private func generateUniqueModels(key: String,
if let rng = name.range(of: String.setCallCountSuffix) {
return (String(name[name.startIndex..<rng.lowerBound]), element)
}
// Before the generic `CallCount` strip, else `stateGetCallCount` keys to `stateGet`.
if let rng = name.range(of: String.getCallCountSuffix) {
return (String(name[name.startIndex..<rng.lowerBound]), element)
}
if let rng = name.range(of: String.callCountSuffix) {
return (String(name[name.startIndex..<rng.lowerBound]), element)
}
Expand Down
12 changes: 12 additions & 0 deletions Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,13 @@ extension VariableDeclSyntax {
storageKind = .stored(needsSetCount: true)
}

let getterHistory: VariableModel.GetterHistory
switch metadata?.varsWithGetterHistory?[name] ?? metadata?.varsWithGetterHistory?[String.all] {
case .some(true): getterHistory = .enabled
case .some(false): getterHistory = .disabled
case .none: getterHistory = .unspecified
}

return VariableModel(name: name,
type: swiftType,
acl: acl,
Expand All @@ -500,6 +507,7 @@ extension VariableDeclSyntax {
offset: v.offset,
rxTypes: metadata?.varTypes,
customModifiers: metadata?.modifiers,
getterHistory: getterHistory,
modelDescription: self.description,
combineType: metadata?.combineTypes?[name] ?? metadata?.combineTypes?["all"],
processed: processed)
Expand Down Expand Up @@ -899,6 +907,10 @@ extension Trivia {

ret.funcsWithArgsHistory = arguments.compactMap { k, v in v == "true" ? k : nil }
}
if let arguments = parseArguments(argsStr, identifier: .getterColon) {

ret.varsWithGetterHistory = arguments.compactMapValues { Bool($0) }
}
if let arguments = parseArguments(argsStr, identifier: .combineColon) {

ret.combineTypes = ret.combineTypes ?? [String: CombineType]()
Expand Down
37 changes: 27 additions & 10 deletions Sources/MockoloFramework/Templates/NominalTemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,25 @@ extension NominalModel {
entities: [(String, Model)]) -> 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,
Expand Down Expand Up @@ -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
)

Expand Down Expand Up @@ -110,6 +122,7 @@ extension NominalModel {
declaredInits: [MethodModel],
acl: String,
declKindOfMockAnnotatedBaseType: NominalTypeDeclKind,
getterTrackedBackingNames: Set<String>,
context: RenderContext,
arguments: GenerationArguments
) -> String {
Expand Down Expand Up @@ -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) }"
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
37 changes: 35 additions & 2 deletions Sources/MockoloFramework/Templates/VariableTemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand Down Expand Up @@ -117,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)}
"""
Expand Down
Loading