Created
September 12, 2023 09:56
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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