Skip to content

Instantly share code, notes, and snippets.

@PatrykKaczmarek
Created September 12, 2023 09:56
Show Gist options
  • Save PatrykKaczmarek/0f3cdad536e04a9bcbad3c2a0a2415d6 to your computer and use it in GitHub Desktop.
Save PatrykKaczmarek/0f3cdad536e04a9bcbad3c2a0a2415d6 to your computer and use it in GitHub Desktop.
An easy way to store test data in the json files, read them, change them on the fly and use as a Dictionary or Decodable model.
import XCTest
/// JSON supports two widely used (amongst programming languages) data structures.
/// This protocol is to define them.
protocol JSONStructure {
associatedtype Container: Any
static var container: Container { get }
}
/// **A collection of name/value pairs**.
/// In `Swift.Codable` known as `KeyedEncodingContainer`.
/// In other programming languages, it is called as object, record, struct, dictionary, hash table, keyed list, or associative array.
enum KeyedJSONContainer: JSONStructure {
typealias Container = [String: Any]
static var container: Container { [:] }
}
/// **An ordered list of values**.
/// In `Swift.Codable` known as `UnkeyedEncodingContainer`.
/// In other programming languages, it is called as array, vector, list, or sequence.
enum UnkeyedJSONContainer: JSONStructure {
typealias Container = [[String: Any]]
static var container: Container { [] }
}
enum TestBundleJSONDecoder {
/// Errors that might be thrown by `TestBundleJSONDecoder`.
enum RawError: Error {
/// Indicates that file with given name does not exist in test bundle.
case fileNotFound(_ fileName: String)
/// Indicates that specified container type does not match the structure in the decoded file.
case invalidContainerType(expected: Any.Type, got: Any.Type)
var localizedDescription: String {
switch self {
case .fileNotFound(let fileName):
let bundle = TestBundle.name
return """
Unable to find a file named \(fileName) in the \(bundle) bundle.
Look for typos in the name. Remember file has to be a member of the target.
"""
case .invalidContainerType(let expected, let got):
return """
Top level structure of the json file does not match expected one.
\n
Expected: \(expected).
Got: \(got).
\n
Revisit file structure or value given in `container` parameter.
"""
}
}
}
/// Creates a json from given file. Allows customization.
/// - Parameters:
/// - fileName: A name of the file where the json is located. Pass value without `.json` extension.
/// - container: A type of the json structure. Can be either keyed or unkeyed.
/// - customizeContainer: An optional closure that allows to customize json before returning it.
/// - file: File path to display XCTest failure popup in a proper file. **Do not override.**
/// - line: A line to display XCTest failure popup on a proper line within the file. **Do not override.**
/// - Returns: In case of successful parsing, returns json. Otherwise throws an error.
/// - Throws: `TestBundleJSONDecoder.RawError`.
static func json<U: JSONStructure>(
fromFile fileName: String,
container: U.Type,
customizeContainer: ((inout U.Container) -> Void)? = nil,
file: StaticString = #filePath,
line: UInt = #line
) throws -> U.Container {
/// Retrieve path to file.
let path = TestBundle.bundle.path(forResource: fileName, ofType: "json")
guard let path else {
let error = RawError.fileNotFound(fileName.appending(".json"))
XCTFail(error.localizedDescription, file: file, line: line)
throw error
}
/// Convert content of file to `Data` object.
let data = try Data(
contentsOf: URL(fileURLWithPath: path),
options: .mappedIfSafe
)
/// Create json from retrieved data.
let json = try JSONSerialization.jsonObject(
with: data,
options: .allowFragments
)
/// Ensure that structure of the json matches expected one.
guard var container = json as? U.Container else {
let error = RawError.invalidContainerType(
expected: U.Container.self,
got: type(of: json)
)
XCTFail(error.localizedDescription, file: file, line: line)
throw error
}
/// If customization is not needed, just return genuine content of the file.
guard let customizeContainer else {
return container
}
/// Otherwise invoke customization closure, then return customized container.
customizeContainer(&container)
return container
}
/// Grabs file content and creates a model from it. Allows customization.
/// - Parameters:
/// - model: A name of the model that should be decoded.
/// - fileName: A name of the file where the json is located. Pass value without `.json` extension.
/// - decoder: A decoder used for decoding data taken from file. Inject in case of custom decoding strategy.
/// - container: A type of the json structure. Can be either keyed or unkeyed.
/// - customizeContainer: An optional closure that allows to customize json before turning it into model.
/// - file: File path to display XCTest failure popup in a proper file. **Do not override.**
/// - line: A line to display XCTest failure popup on a proper line within the file. **Do not override.**
/// - Returns: In case of successful decoding, returns a decoded model. Otherwise throws an error.
/// - Throws: `TestBundleJSONDecoder.RawError`.
static func decode<T: Decodable, U: JSONStructure>(
model: T.Type,
fromFile fileName: String,
decoder: JSONDecoder = JSONDecoder(),
container: U.Type,
customizeContainer: ((inout U.Container) -> Void)? = nil,
file: StaticString = #filePath,
line: UInt = #line
) throws -> T {
let json = try json(
fromFile: fileName,
container: container,
customizeContainer: customizeContainer,
file: file,
line: line
)
let data = try JSONSerialization.data(withJSONObject: json)
return try decoder.decode(model, from: data)
}
}
private final class TestBundle {
static var bundle: Bundle {
Bundle(for: type(of: TestBundle().self))
}
static var name: String {
bundle.bundlePath.components(separatedBy: "/").last ?? "unknown"
}
}
/*
/// Usage:
/// Gain a dictionary from a file named "file_from_test_bundle_without_json_extension" to e.g. stub HTTP call:
let dictionary = try TestBundleJSONDecoder.json(
fromFile: <#file_from_test_bundle_without_json_extension#>,
container: KeyedJSONContainer.self // or UnkeyedJSONContainer.self
)
/// Gain a model from a file named "file_from_test_bundle_without_json_extension":
let model = try TestBundleJSONDecoder.decode(
model: <#Model#>.self,
fromFile: <#file_from_test_bundle_without_json_extension#>,
container: KeyedJSONContainer.self // or UnkeyedJSONContainer.self
)
/// JSON customization for `KeyedJSONContainer`:
let model = try TestBundleJSONDecoder.decode(
model: <#Model#>.self,
fromFile: <#file_from_test_bundle_without_json_extension#>,
container: KeyedJSONContainer.self
customizeContainer: { json in // json is already a dictionary
json["email"] = nil /// remove email from the json body
json.append(["foo": "bar"]) /// compiler time error
}
)
/// JSON customization for `UnkeyedJSONContainer`:
let model = try TestBundleJSONDecoder.decode(
model: <#Model#>.self,
fromFile: <#file_from_test_bundle_without_json_extension#>,
container: UnkeyedJSONContainer.self
customizeContainer: { json in // json is already an array of objects
json["email"] = nil // compiler time error
json.append(["sex": "other"]) /// add another object
}
)
/// Example model defition:
struct User: Decodable {
let email: String
let id: String
let username: String
}
/// Example of `KeyedJSONContainer` json, e.g. /users/me endpoint:
{
"email": "[email protected]",
"id": "b997cb98-fe0d-4f81-bc79-ccb1d7034e20",
"username": "fixture.user"
}
/// Example of `UnkeyedJSONContainer` json, e.g. /users endpoint:
[
{
"email": "[email protected]",
"id": "b997cb98-fe0d-4f81-bc79-ccb1d7034e20",
"username": "fixture.user.1"
},
{
"email": "[email protected]",
"id": "aff7cbf7-db65-458a-92c3-1ee59cbcc026",
"username": "fixture.user.2"
}
]
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment