Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Unit tests for milestone actions #425

Open
wants to merge 12 commits into
base: trunk
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ def self.run(params)

github_helper = Fastlane::Helper::GithubHelper.new(github_token: params[:github_token])
last_stone = github_helper.get_last_milestone(repository)

UI.user_error!('No milestone found on the repository.') if last_stone.nil?
UI.user_error!("Milestone #{last_stone[:title]} has no due date.") if last_stone[:due_on].nil?
raafaelima marked this conversation as resolved.
Show resolved Hide resolved

UI.message("Last detected milestone: #{last_stone[:title]} due on #{last_stone[:due_on]}.")
milestone_duedate = last_stone[:due_on]
milestone_duration = params[:milestone_duration]
Expand Down Expand Up @@ -49,19 +53,19 @@ def self.available_options
env_name: 'GHHELPER_NEED_APPSTORE_SUBMISSION',
description: 'True if the app needs to be submitted',
optional: true,
is_string: false,
type: Boolean,
Copy link
Contributor Author

@raafaelima raafaelima Nov 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-Review: As the is_string is deprecated on the ConfigItems, I replace them with the type of variable that we want to receive. This also applies to the other places where I did that change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great! It's even a small step towards addressing #278 that we opened a while ago about this 🙂

default_value: false),
FastlaneCore::ConfigItem.new(key: :milestone_duration,
env_name: 'GHHELPER_MILESTONE_DURATION',
description: 'Milestone duration in number of days',
optional: true,
is_string: false,
type: Integer,
default_value: 14),
FastlaneCore::ConfigItem.new(key: :number_of_days_from_code_freeze_to_release,
env_name: 'GHHELPER_NUMBER_OF_DAYS_FROM_CODE_FREEZE_TO_RELEASE',
description: 'Number of days from code freeze to release',
optional: true,
is_string: false,
type: Integer,
default_value: 14),
Fastlane::Helper::GithubHelper.github_token_config_item,
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ def self.run(params)
UI.user_error!("Milestone #{milestone_title} not found.") if milestone.nil?

mile_title = milestone[:title]
puts freeze
if freeze
# Check if the state needs changes
if is_frozen(milestone)
Expand Down Expand Up @@ -71,7 +70,7 @@ def self.available_options
description: 'The GitHub milestone',
optional: false,
default_value: true,
is_string: false),
type: Boolean),
Fastlane::Helper::GithubHelper.github_token_config_item,
]
end
Expand Down
65 changes: 65 additions & 0 deletions spec/close_milestone_action_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
require 'spec_helper'
require 'shared_examples_for_actions_with_github_token'

describe Fastlane::Actions::CloseMilestoneAction do
let(:test_repository) { 'test-repository' }
let(:test_token) { 'Test-GithubToken-1234' }
let(:test_milestone) do
{ title: '10.1', number: '1234' }
end
let(:default_params) do
{ repository: test_repository,
milestone: test_milestone[:title],
github_token: 'Test-GithubToken-1234' }
end
let(:client) do
instance_double(
Octokit::Client,
list_milestones: [test_milestone],
update_milestone: nil,
user: instance_double('User', name: 'test'),
'auto_paginate=': nil
)
end

before do
allow(Octokit::Client).to receive(:new).and_return(client)
end

it 'closes the expected milestone on the expected repository' do
expect(client).to receive(:update_milestone).with(test_repository, test_milestone[:number], state: 'closed')
run_described_fastlane_action(default_params)
end

it 'raises an error when the milestone is not found or does not exist' do
allow(client).to receive(:list_milestones).and_return([])
expect { run_described_fastlane_action(default_params) }.to raise_error(FastlaneCore::Interface::FastlaneError, 'Milestone 10.1 not found.')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

end

describe 'initialize' do
include_examples 'github_token_initialization'
end

describe 'calling the action validates input' do
it 'raises an error if no `GHHELPER_REPOSITORY` environment variable nor parameter `:repository` is present' do
expect { run_action_without(:repository) }.to raise_error(FastlaneCore::Interface::FastlaneError, "No value found for 'repository'")
end

it 'raises an error if no `GHHELPER_MILESTONE` environment variable nor parameter `:milestone` is present' do
expect { run_action_without(:milestone) }.to raise_error(FastlaneCore::Interface::FastlaneError, "No value found for 'milestone'")
end

it 'raises an error if `milestone:` parameter is passed as Integer' do
expect { run_action_with(milestone: 10) }.to raise_error "'milestone' value must be a String! Found Integer instead."
end
end

def run_action_with(**keys_and_values)
params = default_params.merge(keys_and_values)
run_described_fastlane_action(params)
end

def run_action_without(key)
run_described_fastlane_action(default_params.except(key))
end
end
138 changes: 138 additions & 0 deletions spec/create_new_milestone_action_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
require 'spec_helper'
require 'shared_examples_for_actions_with_github_token'

describe Fastlane::Actions::CreateNewMilestoneAction do
let(:test_repository) { 'test-repository' }
let(:test_milestone) do
{ title: '10.1', number: '1234', due_on: '2022-10-31T07:00:00Z' }
end
let(:milestone_list) do
[
{ title: '10.2', number: '1234', due_on: '2022-10-31T12:00:00Z' },
{ title: '10.3', number: '4567', due_on: '2022-11-02T15:00:00Z' },
{ title: '10.4', number: '7890', due_on: '2022-11-04T23:59:00Z' },
]
end
let(:default_params) do
{ repository: test_repository,
need_appstore_submission: false,
github_token: 'Test-GithubToken-1234' }
end
let(:client) do
instance_double(
Octokit::Client,
list_milestones: [test_milestone],
create_milestone: nil,
user: instance_double('User', name: 'test'),
'auto_paginate=': nil
)
end

before do
allow(Octokit::Client).to receive(:new).and_return(client)
end

describe 'date computation is correct' do
it 'computes the correct due date and milestone description' do
comment = "Code freeze: November 14, 2022\nApp Store submission: November 28, 2022\nRelease: November 28, 2022\n"
expect(client).to receive(:create_milestone).with(test_repository, '10.2', due_on: '2022-11-14T12:00:00Z', description: comment)
run_described_fastlane_action(default_params)
end

it 'removes 3 days from the AppStore submission date when `:need_appstore_submission` is true' do
comment = "Code freeze: November 14, 2022\nApp Store submission: November 25, 2022\nRelease: November 28, 2022\n"
expect(client).to receive(:create_milestone).with(test_repository, '10.2', due_on: '2022-11-14T12:00:00Z', description: comment)
run_action_with(need_appstore_submission: true)
end

it 'uses the most recent milestone date to calculate the due date and version of new milestone' do
comment = "Code freeze: November 18, 2022\nApp Store submission: December 02, 2022\nRelease: December 02, 2022\n"
allow(client).to receive(:list_milestones).and_return(milestone_list)
expect(client).to receive(:create_milestone).with(test_repository, '10.5', due_on: '2022-11-18T12:00:00Z', description: comment)
run_described_fastlane_action(default_params)
end

context 'when last milestone cannot be used' do
it 'raises an error when the due date of milestone does not exists' do
allow(client).to receive(:list_milestones).and_return([{ title: '10.1', number: '1234' }])
expect { run_described_fastlane_action(default_params) }.to raise_error(FastlaneCore::Interface::FastlaneError, 'Milestone 10.1 has no due date.')
end

it 'raises an error when the milestone is not found or does not exist on the repository' do
allow(client).to receive(:list_milestones).and_return([])
expect { run_described_fastlane_action(default_params) }.to raise_error(FastlaneCore::Interface::FastlaneError, 'No milestone found on the repository.')
end
end
end

describe 'initialize' do
include_examples 'github_token_initialization'

context 'when using default parameters' do
let(:github_helper) do
instance_double(
Fastlane::Helper::GithubHelper,
get_last_milestone: test_milestone,
create_milestone: nil
)
end

before do
allow(Fastlane::Helper::GithubHelper).to receive(:new).and_return(github_helper)
end

it 'uses default value when neither `GHHELPER_NUMBER_OF_DAYS_FROM_CODE_FREEZE_TO_RELEASE` environment variable nor parameter `:number_of_days_from_code_freeze_to_release` is present' do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gosh we really need to refactor the API of this action to use nicer and more comprehensible / consistent ConfigItem parameter names for that one… those are quite a mouthful currently 😅

One day… in another PR… in the far future… maybe…

default_code_freeze_days = 14
expect(github_helper).to receive(:create_milestone).with(
anything,
anything,
anything,
anything,
default_code_freeze_days,
anything
)
Comment on lines +86 to +93
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it wouldn't be the occasion to make this GithubHelper#create_milestone method start using named parameters instead of positional ones — that are even harder to identify in test cases like this amongst the other anything placeholders. That should then allow us to use .with(hash_including(code_freeze_days: 14)) instead of having to use all those anything placeholders there.

But that means a small refactoring of all the call sites though (which might be outside of this PR's scope… but depending on how big a change it would be—not sure there are that many call sites, the create_new_milestone action might even be the only one?—, that could still be worth it so we can then have both the call sites and those unit tests be way nicer…
I'll let you evaluate how much changes it would warrant to do this, and decide if it's worth it or not.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I see, the only places that will be affected are the GithubSpec and the create_milestone_action/Spec, I agree that this method should have the named params to "force" it to be better legible and cleaner.

However, TBH I do not feel that I should do it on this PR, as it is a bit out of scope (although is a small change). So, I'll open another PR to deal with this, and update these tests here once this new PR is merged 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: This is addressed on PR #426 😄

run_described_fastlane_action(default_params)
end

it 'uses default value when neither `GHHELPER_MILESTONE_DURATION` environment variable nor parameter `:milestone_duration` is present' do
default_milestone_duration = 14
expect(github_helper).to receive(:create_milestone).with(
anything,
anything,
anything,
default_milestone_duration,
anything,
anything
)
run_described_fastlane_action(default_params)
end
end
end

describe 'calling the action validates input' do
it 'raises an error if no `GHHELPER_REPOSITORY` environment variable nor parameter `:repository` is present' do
expect { run_action_without(:repository) }.to raise_error(FastlaneCore::Interface::FastlaneError, "No value found for 'repository'")
end

it 'raises an error if `need_appstore_submission:` parameter is passed as String' do
expect { run_action_with(need_appstore_submission: 'foo') }.to raise_error "'need_appstore_submission' value must be either `true` or `false`! Found String instead."
end

it 'raises an error if `milestone_duration:` parameter is passed as String' do
expect { run_action_with(milestone_duration: 'foo') }.to raise_error "'milestone_duration' value must be a Integer! Found String instead."
end

it 'raises an error if `number_of_days_from_code_freeze_to_release:` parameter is passed as String' do
expect { run_action_with(number_of_days_from_code_freeze_to_release: 'foo') }.to raise_error "'number_of_days_from_code_freeze_to_release' value must be a Integer! Found String instead."
end
end

def run_action_with(**keys_and_values)
params = default_params.merge(keys_and_values)
run_described_fastlane_action(params)
end

def run_action_without(key)
run_described_fastlane_action(default_params.except(key))
end
end
89 changes: 89 additions & 0 deletions spec/setfrozentag_action_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
require 'spec_helper'
require 'shared_examples_for_actions_with_github_token'

describe Fastlane::Actions::SetfrozentagAction do
let(:test_repository) { 'test-repository' }
let(:test_token) { 'Test-GithubToken-1234' }
let(:test_milestone) do
{ title: '10.1', number: '1234' }
end
let(:default_params) do
{
repository: test_repository,
milestone: test_milestone[:title],
freeze: true,
github_token: 'Test-GithubToken-1234'
}
end
let(:client) do
instance_double(
Octokit::Client,
list_milestones: [test_milestone],
update_milestone: nil,
user: instance_double('User', name: 'test'),
'auto_paginate=': nil
)
end

before do
allow(Octokit::Client).to receive(:new).and_return(client)
end

it 'raises an error when the milestone is not found or does not exist' do
allow(client).to receive(:list_milestones).and_return([])
expect { run_described_fastlane_action(default_params) }.to raise_error(FastlaneCore::Interface::FastlaneError, 'Milestone 10.1 not found.')
end

it 'freezes the milestone adding ❄️ to the title' do
expect(client).to receive(:update_milestone).with(test_repository, test_milestone[:number], title: '10.1 ❄️')
run_action_with(freeze: true)
end

it 'remove any existing ❄️ emoji from a frozen milestone' do
allow(client).to receive(:list_milestones).and_return([{ title: '10.2 ❄️', number: '1234' }])
expect(client).to receive(:update_milestone).with(test_repository, test_milestone[:number], title: '10.2')
run_action_with(freeze: false, milestone: '10.2')
end

it 'does not change a milestone that is already frozen' do
allow(client).to receive(:list_milestones).and_return([{ title: '10.2 ❄️', number: '1234' }])
expect(client).not_to receive(:update_milestone)
run_action_with(milestone: '10.2 ❄️')
end

it 'does not change an unfrozen milestone if :freeze parameter is false' do
expect(client).to receive(:update_milestone).with(test_repository, test_milestone[:number], title: '10.1')
run_action_with(freeze: false)
end
Comment on lines +37 to +57
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


describe 'initialize' do
include_examples 'github_token_initialization'
end

describe 'Calling the Action validates input' do
it 'raises an error if no `GHHELPER_REPOSITORY` environment variable nor parameter `:repository` is present' do
expect { run_action_without(:repository) }.to raise_error(FastlaneCore::Interface::FastlaneError, "No value found for 'repository'")
end

it 'raises an error if no `GHHELPER_MILESTORE` environment variable nor parameter `:milestone` is present' do
expect { run_action_without(:milestone) }.to raise_error(FastlaneCore::Interface::FastlaneError, "No value found for 'milestone'")
end

it 'raises an error if `:freeze` parameter is passed as String' do
expect { run_action_with(freeze: 'foo') }.to raise_error "'freeze' value must be either `true` or `false`! Found String instead."
end

it 'raises an error if `:milestone` parameter is passed as Integer' do
expect { run_action_with(milestone: 10) }.to raise_error "'milestone' value must be a String! Found Integer instead."
end
end

def run_action_with(**keys_and_values)
params = default_params.merge(keys_and_values)
run_described_fastlane_action(params)
end

def run_action_without(key)
run_described_fastlane_action(default_params.except(key))
end
end
29 changes: 29 additions & 0 deletions spec/shared_examples_for_actions_with_github_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
require 'spec_helper'

RSpec.shared_examples 'github_token_initialization' do
let(:test_token) { 'Test-GithubToken-1234' }

describe 'GitHub Token is properly passed to the client' do
it 'properly passes the environment variable `GITHUB_TOKEN` to Octokit::Client' do
ENV['GITHUB_TOKEN'] = test_token
expect(Octokit::Client).to receive(:new).with(access_token: test_token)
run_action_without(:github_token)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shared example relies on the spec including it (include_examples 'github_token_initialization') defining a run_action_without method and a default_params. The only way a consumer would find out about this would be by running the tests.

E.g. if we rename run_action_without to something else in setfrozentag_action_spec, we get:

Failures:

  1) Fastlane::Actions::SetfrozentagAction initialize GitHub Token is properly passed to the client properly passes the environment variable `GITHUB_TOKEN` to Octokit::Client
     Failure/Error: run_action_without(:github_token)

     NoMethodError:
       undefined method `run_action_without' for #<RSpec::ExampleGroups::FastlaneActionsSetfrozentagAction::Initialize::GitHubTokenIsProperlyPassedToTheClient:0x0000000109277748>
       Did you mean?  run_action_with
     Shared Example Group: "github_token_initialization" called from ./spec/setfrozentag_action_spec.rb:60
     ...

In Swift, we'd do something like constraining this to tests that conform to a certain protocol. There's no such thing in vanilla Ruby that I'm aware of.

There might be a way to update run_described_fastlane_action to add it the capability to do _with and _without, but that wouldn't solve the implicit dependency on default_params 🤔

At this point, the only thing I can think of is to document the requirement as a comment at the start of the file. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shared examples can also contain metadata, that you can then pass as additional params when you call include_examples. I think that would be a better way to pass default_params.

As for _with and _without variants, I wonder if it's worth relying on those existing in the implementation of shared_examples vs just directly using default_params.except(…) and default_params.merge(…) so that we don't rely on those _with[out] methods existing

Copy link
Contributor Author

@raafaelima raafaelima Nov 10, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shared examples can also contain metadata, that you can then pass as additional params when you call include_examples. I think that would be a better way to pass default_params.

Indeed, when I try to use the shared examples passing the default_params as metadata, the RSpec blocked me from doing so. The error that I receive is: As we have the default_params as a :let variable it is not available on an example group (e.g. a describe or context block). It is only available within individual examples (e.g. it blocks).

In that case, I don't know what is the best alternative here, if is to keep the shared examples relying on the existence of a variable named default_params and document this as @mokagio suggested. Or to redundant use another :let variable to pass that metadata as in this example 🤔

  describe 'initialize' do
    include_examples 'github_token_initialization' do
      let(:params) { default_params }
    end
  end

As for _with and _without variants, I wonder if it's worth relying on those existing in the implementation of shared_examples vs just directly using default_params.except(…) and default_params.merge(…) so that we don't rely on those _with[out] methods existing

Yeah, I have that "dilemma" when I was implementing this, I was first defaulting to those approaches of having the merge and except instead of relying on the _with and _without functions to exist. However, as I didn't find a good way to pass the params to inside the shared models, I end up following the same pattern that I was in the examples and relying on the existence of those methods as I do not want to duplicate them inside the shared examples.

Copy link
Contributor Author

@raafaelima raafaelima Nov 10, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, there is this third option to do it, which is cleaner than the other one, but I find it a little bit confusing the way that it used the send with the variable name (from a perspective of a person who does not has so much knowledge of ruby 😅)

shared_examples 'github_token_initialization' do |params:|
  let(:params) { send(params) }
  ....
end

# spec/test.rb
describe 'initialize' do
 include_examples 'github_token_initialization', params: :default_params
end

But again, although I feel that this third option is cleaner, I have no strong option about which one is the right one that we should use here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tbh I'm not a fan of using send(…), both because it's lesser known, but also because it feels like a trick more than a proper official way to do this (the times I've used it was mostly to workaround some limitations rather than to implement something cleanly).

What about using metadata, but workaround the limitation that let is only visible in example and not groups… by declaring default_params as a function (def) rather than a let? I know that's what you had in an earlier iteration of the PR before I gave you some feedback that let is usually better suited for constant values, and that's still true, but if that is a blocker for us using it in include_examples' metadata, then I think it's ok to go back to def for that case then. At least I'd personally prefer this over using send 🙃

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I find the use of send kinda odd too 🙃.

However, even if I change default_params to a def function, I still receive the same error from the RSpec as using it as a let variable.

def default_params
{ 
  repository: 'test-repository',
  milestone: '10.1',
  github_token: 'Test-GithubToken-1234' 
}
end

#When I call
describe 'initialize' do
 include_examples 'github_token_initialization', default_params
end

# I receive the same error as the let
Failure/Error: include_examples 'github_token_initialization', default_params
  `default_params` is not available on an example group (e.g. a `describe` or `context` block). It is only available from within individual examples (e.g. `it` blocks) or from constructs that run in the scope of an example (e.g. `before`, `let`, etc).

The only way that seems to do the job (although is not a DRY solution), without using the send, Is to repeat the declaration of the parameters at the call of the shared_examples like this:

describe 'initialize' do
  include_examples 'github_token_initialization',  { repository: 'test-repository', milestone: '10.1', github_token: 'Test-GithubToken-1234' }
end

Let me know what you think about this suggestion 😄

end

it 'properly passes the parameter `:github_token` to Octokit::Client' do
expect(Octokit::Client).to receive(:new).with(access_token: test_token)
run_described_fastlane_action(default_params)
end

it 'prioritizes `:github_token` parameter over `GITHUB_TOKEN` environment variable if both are present' do
ENV['GITHUB_TOKEN'] = 'Test-EnvGithubToken-1234'
expect(Octokit::Client).to receive(:new).with(access_token: test_token)
run_described_fastlane_action(default_params)
end

it 'raises an error if no `GITHUB_TOKEN` environment variable nor parameter `:github_token` is present' do
ENV['GITHUB_TOKEN'] = nil
expect { run_action_without(:github_token) }.to raise_error(FastlaneCore::Interface::FastlaneError, "No value found for 'github_token'")
end
end
end