Skip to content

Commit

Permalink
Allow dynamic instance method calls on FactoryBot objects in Cypress (#…
Browse files Browse the repository at this point in the history
…696)

* Init with first ideas about proxies (not working yet)

* Improve approach (still doesn't work)

* Include temporary FactoryBot dynamic method assignment test

* Add example for direct method assignment

* Add first working example of dynamic method creation

* Refactor first working minimal example

* Allow dynamic method access only on .call object

This is due to
https://stackoverflow.com/q/79016566/9655481

* Implement call to instance methods

* Also wrap `.then()` call

* Remove custom rails number test method

* Improve create() method docstring

* Rename method to avoid confusion with "factories"

* Add docstrings to transform_hash function & don't use Regex

* Add Cypress tests to test array as argument

* Allow creation of multiple users of same role

e.g. multiple generic users

* Fix creation of user in cypress tests

This failed due to random hash added to the mail address
in the last commit.
  • Loading branch information
Splines authored Sep 25, 2024
1 parent 49cf16a commit 76e3329
Show file tree
Hide file tree
Showing 6 changed files with 212 additions and 23 deletions.
86 changes: 72 additions & 14 deletions app/controllers/cypress/factories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,57 @@ module Cypress
# It is inspired by this blog post by Tom Conroy:
# https://tbconroy.com/2018/04/07/creating-data-with-factorybot-for-rails-cypress-tests/
class FactoriesController < CypressController
# Wrapper around FactoryBot.create to create a factory via a POST request.
# Creates an instance of the factory (via FactoryBot) and returns it as JSON.
def create
unless params["0"].is_a?(String)
msg = "First argument must be a string indicating the factory name."
msg += " But we got: '#{params["0"]}'"
raise(ArgumentError, msg)
end
factory_name = validate_factory_name(params["0"])
attributes, should_validate = params_to_attributes(
params.except(:controller, :action, :number)
)
res = create_class_instance_via_factorybot(attributes, should_validate)

# The factory name is included in the response such that it can be passed
# to call_instance_method later on in order to determine the class of the instance.
render json: res.as_json.merge({ factory_name: factory_name }), status: :created
end

attributes, should_validate = params_to_attributes(params.except(:controller, :action,
:number))
# Calls the instance method on the instance created by FactoryBot.create().
# Expects as arguments the factory name, the id of the instance,
# the method name and the method arguments to be passed to the instance method.
def call_instance_method
factory_name = validate_factory_name(params["factory_name"]).capitalize
id = params["instance_id"].to_i
method_name = params["method_name"]
method_args = params["method_args"]
method_args, _validate = params_to_attributes(method_args) if method_args.present?

res = if should_validate
FactoryBot.create(*attributes) # default case
else
FactoryBot.build(*attributes).tap { |instance| instance.save(validate: false) }
# Find the instance
begin
instance = factory_name.constantize.find(id)
rescue ActiveRecord::RecordNotFound
result = { error: "Instance where you'd like to call '#{method_name}' on was not found" }
return render json: result.to_json, status: :bad_request
end

render json: res.to_json, status: :created
# Call the instance method & return the result
begin
result = instance.send(method_name, *method_args)
render json: result.to_json, status: :created
rescue NoMethodError => _e
result = { error: "Method '#{method_name}' not found on instance" }
render json: result.to_json, status: :bad_request
end
end

private

def validate_factory_name(factory_name)
return factory_name if factory_name.is_a?(String)

msg = "First argument must be a string indicating the factory name."
msg += " But we got: '#{factory_name}'"
raise(ArgumentError, msg)
end

def params_to_attributes(params)
should_validate = true

Expand All @@ -35,7 +64,7 @@ def params_to_attributes(params)
if value.key?("validate")
should_validate = (value["validate"] != "false")
else
value.transform_keys(&:to_sym)
transform_hash(value)
end
elsif value.is_a?(String)
value.to_sym
Expand All @@ -46,5 +75,34 @@ def params_to_attributes(params)

return attributes, should_validate
end

# Converts the keys of the hash to symbols. Furthermore, if the hash
# contains nested hashes with keys that are all integers, it converts
# the nested hashes to arrays of strings.
#
# The latter is important for calls like the following in Cypress:
# FactoryBot.create("tutorial",
# { lecture_id: this.lecture.id, tutor_ids: [this.tutor1.id, this.tutor2.id] }
# )
# Without this transformation, the create() method in this controller
# would receive [:tutorial, {"lecture_id"=>"1", "tutor_ids"=>{"0"=>"42", "1"=>"43"}}],
# whereas what we need is: [:tutorial, {"lecture_id"=>"1", "tutor_ids"=>["42", "43"]}].
def transform_hash(value)
value.transform_keys(&:to_sym).transform_values do |v|
if v.is_a?(Hash) && v.keys.all? { |key| key.to_i.to_s }
v.values.map(&:to_s)
else
v
end
end
end

def create_class_instance_via_factorybot(attributes, should_validate)
if should_validate
FactoryBot.create(*attributes) # default case
else
FactoryBot.build(*attributes).tap { |instance| instance.save(validate: false) }
end
end
end
end
10 changes: 7 additions & 3 deletions app/controllers/cypress/user_creator_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
module Cypress
# Creates a user for use in Cypress tests.
class UserCreatorController < CypressController
CYPRESS_PASSWORD = "cypress123".freeze

def create
unless params[:role].is_a?(String)
msg = "First argument must be a string indicating the user role."
Expand All @@ -10,13 +12,15 @@ def create

role = params[:role]
is_admin = (role == "admin")
random_hash = SecureRandom.hex(6)

user = User.create(name: "#{role} Cypress", email: "#{role}@mampf.cypress",
password: "cypress123", consents: true, admin: is_admin,
user = User.create(name: "#{role} Cypress #{random_hash}",
email: "#{role}-#{random_hash}@mampf.cypress",
password: CYPRESS_PASSWORD, consents: true, admin: is_admin,
locale: I18n.default_locale)
user.confirm

render json: user.to_json, status: :created
render json: user.as_json.merge({ password: CYPRESS_PASSWORD }), status: :created
end
end
end
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
if Rails.env.test?
namespace :cypress do
resources :factories, only: :create
post "factories/call_instance_method", to: "factories#call_instance_method"
resources :database_cleaner, only: :create
resources :user_creator, only: :create
resources :i18n, only: :create
Expand Down
47 changes: 47 additions & 0 deletions spec/cypress/e2e/meta/factory_bot_spec.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import FactoryBot from "../../support/factorybot";

describe("FactoryBot.create()", () => {
beforeEach(function () {
cy.createUserAndLogin("teacher").as("teacher");
});

it("allows to call create() with array as argument", function () {
cy.createUser("generic").as("tutor1");
cy.createUser("generic").as("tutor2");
FactoryBot.create("lecture", { teacher_id: this.teacher.id }).as("lecture");

cy.then(() => {
// here we pass in an array as argument to tutor_ids
FactoryBot.create("tutorial",
{ lecture_id: this.lecture.id, tutor_ids: [this.tutor1.id, this.tutor2.id] },
);
});
});
});

describe("FactoryBot.create().call", () => {
beforeEach(function () {
cy.createUser("teacher").as("teacher");
cy.createUserAndLogin("generic");
});

it("allows to call instance methods after assigning them to an alias", function () {
FactoryBot.create("lecture", { teacher_id: this.teacher.id }).as("lecture");

cy.then(() => {
// via alias in global this namespace
this.lecture.call.long_title().then((res) => {
cy.log(res);
});
});
});

it("allows to call instance methods directly (without an alias)", function () {
FactoryBot.create("lecture", { teacher_id: this.teacher.id }).then((lecture) => {
// via return value of FactoryBot.create() directly (no alias intermediate)
lecture.call.long_title().then((res) => {
cy.log(res);
});
});
});
});
4 changes: 2 additions & 2 deletions spec/cypress/support/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ Cypress.Commands.add("login", (user) => {
});

Cypress.Commands.add("createUserAndLogin", (role) => {
cy.createUser(role).then((user) => {
cy.login({ email: `${role}@mampf.cypress`, password: "cypress123" }).then((_) => {
return cy.createUser(role).then((user) => {
cy.login({ email: user.email, password: user.password }).then((_) => {
cy.wrap(user);
});
});
Expand Down
87 changes: 83 additions & 4 deletions spec/cypress/support/factorybot.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,98 @@ class FactoryBot {
* @param args The arguments to pass to FactoryBot.create(), e.g.
* factory name, traits, and attributes. Pass them in as separated
* string arguments. Attributes should be passed as an object.
* @returns The FactoryBot.create() response
*
* @example
* You are also able to call instance methods on the created record later.
* @returns The FactoryBot.create() response.
* @examples
* FactoryBot.create("factory_name", "with_trait", { another_attribute: ".pdf"})
* FactoryBot.create("factory_name").then(res => {res.call.any_rails_method(42)})
* FactoryBot.create("tutorial",
* { lecture_id: this.lecture.id, tutor_ids: [this.tutor1.id, this.tutor2.id] })
*/
create(...args) {
return BackendCaller.callCypressRoute("factories", "FactoryBot.create()", args);
const response = BackendCaller.callCypressRoute("factories", "FactoryBot.create()", args);
return this.#createProxy(response);
}

createNoValidate(...args) {
args.push({ validate: false });
return this.create(...args);
}

/**
* Wraps the given Cypress response such that arbitrary methods (dynamic methods)
* can be called on the resulting object.
*/
#createProxy(obj) {
const outerContext = this;

return new Proxy(obj, {
get: function (target, property, receiver) {
if (property !== "as" && property !== "then") {
return Reflect.get(target, property, receiver);
}

// Trap the Cypress "as" and "then" methods to allow dynamic method calls
return function (...asOrThenArgs) {
if (property === "then") {
const callback = asOrThenArgs[0];
asOrThenArgs[0] = function (callbackObj) {
outerContext.#defineCallProperty(callbackObj);
return callback(callbackObj);
};
return target[property](...asOrThenArgs);
}

if (property === "as") {
return target.as(...asOrThenArgs).then((asResponse) => {
outerContext.#defineCallProperty(asResponse);
});
}

throw new Error(`Unknown property that should not be wrapped: ${property}`);
};
},
});
}

#defineCallProperty(response) {
const factoryName = response["factory_name"];
if (!factoryName) {
let msg = "FactoryBot call response does not contain factory_name key.";
msg += " Did you really use FactoryBot.create() (or similar) to create the record?";
throw new Error(msg);
}

if (typeof response.id !== "number") {
let msg = "FactoryBot call response does not contain a valid id key (number).";
msg += " Did you really use FactoryBot.create() (or similar) to create the record?";
throw new Error(msg);
}

const call = this.#allowDynamicMethods({}, factoryName, response.id);
response.call = call;
}

#allowDynamicMethods(obj, factoryName, instanceId) {
return new Proxy(obj, {
get: function (target, property, receiver) {
// If the property does not exist, define it as a new function
if (!(property in target)) {
target[property] = function () {
const payload = {
factory_name: factoryName,
instance_id: instanceId,
method_name: property,
method_args: Array.from(arguments),
};
return BackendCaller.callCypressRoute("factories/call_instance_method",
`FactoryBot.create().call.${property}()`, payload);
};
}
return Reflect.get(target, property, receiver);
},
});
}
}

export default new FactoryBot();

0 comments on commit 76e3329

Please sign in to comment.