CSC/ECE 517 Fall 2018- Project E1848: Writing unit tests for assignment team.rb

From Expertiza_Wiki
Jump to navigation Jump to search

The purpose of this project is to improve the coverage and quality of the unit tests for the assignment team model in Expertiza. This project comprised the program 3 assignment of CSC/ECE 517 in fall 2018 and is entitled "Project E1848: Writing unit tests for assignment_team.rb." The source code for this project is in pull request #1228 of the Expertiza project on GitHub.

Key Contributions

Out of the 30 methods defined for the model AssignmentTeam.rb, test case was written for only one. Our contribution was to successfully add test cases for all remaining 29 methods.

Hence, increasing the coverage from 5% to 100%.

The Class under Test

AssignmentTeam
This class handles all functionalities for a team linked to an assignment. Some of the functionalities include, get a list of participants in the team, get the team name, team score for the given assignment, submissions for this team (files) and (hyperlinks) and topic picked by the team. Add/or remove participants from the team. It also checks for any reviews the team may have gotten for their work. Export all teams in a CSV file, import files from CSV to form teams.


Test Plan

Our test plan was to test every method individually, i.e. write unit tests. We also tested multiple cases for each method. A description of all the methods in the AssignmentTeam model in assignment_team.rb and the RSpec unit tests we wrote for them is below.

The includes?(participant) Method

This function in assignment_team.rb checks if the participant passed as argument is a member of the team or not. For this, we utilize factorybot to build a user and a participant1 as follows-:

  let(:user1) { build(:student, id: 2) }
  let(:participant1) { build(:participant, id: 1) }

Test cases checked for are-:
1) when the team receives a user, that user is allowed to become a team participant. The method returns True for the given participant passed as argument.

    context "when an assignment team has one participant" do
      it "includes one participant" do
        allow(team).to receive(:users).with(no_args).and_return([user1])
        allow(AssignmentParticipant).to receive(:find_by).with(user_id: user1.id, parent_id: team.parent_id).and_return(participant1)
        expect(team.includes?(participant1)).to eq true
      end
    end

2) when the team receives a user with no arguments, that user is not allowed to become a team participant. The method returns False for the given participant passed as argument.

    context "when an assignment team has no users" do
      it "includes no participants" do
        allow(team).to receive(:users).with(no_args).and_return([])
        expect(team.includes?(participant1)).to eq false
      end
    end

The parent_model Method

This function in assignment_team.rb always provides the name of the parent model which is Assignment.

Hence, the unit test also always checks if the response of this method is the string "Assignment" or not.

    it "provides the name of the parent model" do
      expect(team.parent_model).to eq "Assignment"
    end

The self.parent_model(id) Method

This function in assignment_team.rb takes in as argument id of the current Assignment for the team and returns an instance of the parent model.

Hence the unit test checks if the instance returned by this method for a given assignment id, matches the assignment or not.

    it "provides the instance of the parent model" do
      allow(Assignment).to receive(:find).with(1).and_return(assignment)
      expect(AssignmentTeam.parent_model(1)).to eq assignment
    end

The fullname Method

This function in assignment_team.rb print out the name for the current class instance. for this we first build an assignment team instance and assign it the name "abcd".

Hence the unit test checks if the assignment team has a name, and returns that same name.

    context "when the team has a name" do
      it "provides the name of the class" do
        team = build(:assignment_team, id: 1, name: "abcd")
        expect(team.fullname).to eq "abcd"
      end
    end

The review_map_type Method

This function in assignment_team.rb always provides the name of the review map type which is ReviewResponseMap.

Hence, the unit test also always checks if the response of this method is the string "ReviewResponseMap" or not.

    it "provides the review map type" do
      expect(team.review_map_type).to eq "ReviewResponseMap"
    end

The self.prototype Method

This function in assignment_team.rb implements the prototype pattern by returning a new instance of the class AssignmentTeam.

The unit test checks if the response is a new instance of the class or not.

    it "provides the instance of the AssignmentTeam" do
      expect(AssignmentTeam).to receive(:new).with(no_args)
      AssignmentTeam.prototype
    end

The assign_reviewer(reviewer) Method

This function in assignment_team.rb assigns a reviewer to the team by returning a new instance of the class ReviewResponseMap.

The unit test assigns a reviewer to the team and creates an instance of ReviewResponseMap.

  describe "#assign_reviewer" do
    context "when a reviewer is present" do
      it "assign the reviewer to the team" do
        allow(Assignment).to receive(:find).with(team.parent_id).and_return(assignment)
        allow(ReviewResponseMap).to receive(:create).
          with(reviewee_id: team.id, reviewer_id: participant1.id, reviewed_object_id: assignment.id).and_return(review_response_map)
        expect(team.assign_reviewer(participant1)).to eq(review_response_map)
      end
    end
  end

The reviewed_by?(reviewer) Method

This function in assignment_team.rb checks if the team has been reviewed by a reviewer.

The unit test checks if the reviewer has reviewed the team. The test will find instances of ReviewResponseMaps belonging to the reviewer and the team, and will return true if such instances are present.

  describe "#reviewed_by?" do
    context "when a team has a reviewer" do
      it "has been reviewed by this reviewer" do
        template = 'reviewee_id = ? && reviewer_id = ? && reviewed_object_id = ?'
        allow(ReviewResponseMap).to receive(:where).with(template, team.id, participant1.id, team.assignment.id).and_return([review_response_map])
        expect(team.reviewed_by?(participant1)).to eq true
      end
    end

The topic Method

This function in assignment_team.rb return the id of the topic picked by the team for the assignment.

The unit test first stubs a topic for the given assignment, allows a SignedUpTeam object to receive that topic and AssignmentTeam id, and return the topic id to check if it matches with the topic id of the AssignmentTeam.

context "when the team has picked a topic" do
    it "provides the topic id" do
      assignment = team.assignment
      allow(SignUpTopic).to receive(:find_by).with(assignment: assignment).and_return(topic)
      allow(SignedUpTeam).to receive_message_chain(:find_by, :try).with(team_id: team.id).with(:topic_id).and_return(topic.id)
      expect(team.topic).to eq(topic.id)
    end
  end

The has_submissions? Method

this function in assignment_team.rb is used to check if the team has submitted their work or not.

The unit test allows the team to receive submissions, by first receiving files as submission, then hyperlink as submission, and for each expects the the team's submission to be true when the function is called.

    context "when a team has submitted files" do
      it "has submissions" do
        allow(team).to receive_message_chain(:submitted_files, :any?).with(no_args).with(no_args).and_return(true)
      end
    end

    context "when the team has submitted hyperlink" do
      it "checks if the team has submissions" do
        allow(team).to receive_message_chain(:submitted_hyperlinks, :present?).with(no_args).with(no_args).and_return(true)
      end
    end

    after(:each) do
      expect(team.has_submissions?).to be true
    end

The participants Method

This function in assignment_team.rb returns a list of all participants in the team.

The unit test first allows the team to receive two user instances and then adds these users as participants of the team. The test then expects the function to return a list of these two users as participants of the team.

    context "when an assignment team has two participants" do
      it "has those two participants" do
        allow(team).to receive(:users).with(no_args).and_return([user1, user2])
        allow(AssignmentParticipant).to receive(:find_by).with(user_id: user1.id, parent_id: team.parent_id).and_return(participant1)
        allow(AssignmentParticipant).to receive(:find_by).with(user_id: user2.id, parent_id: team.parent_id).and_return(participant2)
        expect(team.participants).to eq [participant1, participant2]
      end
    end

The unit test first allows the team to receive a user instance but does not add this user as participant of the team. The test then expects the function to return an empty list as participants of the team.

    context "when an assignment team has a user but no participants" do
      it "includes no participants" do
        allow(team).to receive(:users).with(no_args).and_return([user1])
        allow(AssignmentParticipant).to receive(:find_by).with(user_id: user1.id, parent_id: team.parent_id).and_return(nil)
        expect(team.participants).to eq []
      end
    end

The delete Method

This function in assignment_team.rb deletes the AssignmentTeam by destroying each participant in the team and returns an instance of AssignmentTeam.

The unit test first allows two users to be added as team participants. The test checks if the team deleted matches the current team instance.

    it "deletes the team" do
      allow(team).to receive(:users).with(no_args).and_return([user1, user2])
      allow(AssignmentParticipant).to receive(:find_by).with(user_id: user1.id, parent_id: team.parent_id).and_return(participant1)
      allow(AssignmentParticipant).to receive(:find_by).with(user_id: user2.id, parent_id: team.parent_id).and_return(participant2)
      expect(team.delete).to eq(team)
    end

The destroy Method

This function in assignment_team.rb deletes all the reviews associated with the team.

The unit test will delete every instance of ReviewResponseMaps associated with the team.

  describe "#destroy" do
    it "delete the reviews" do
      expect(team).to receive_message_chain(:review_response_maps, :each).with(no_args).with(no_args)
      team.destroy
    end
  end

The self.get_first_member(team_id) Method

This function in assignment_team.rb returns the instance of the first participant of the team for the given team id.

The unit test allows the assignment to receive a user, add it as a participant and return the first participant in the team. The test expects the function to return an instance of this participant.

    context "when team id is present" do
      it "get first member of the  team" do
        allow(AssignmentTeam).to receive_message_chain(:find_by, :try, :try).with(id: team.id).with(:participant).with(:first).and_return(participant1)
        expect(AssignmentTeam.get_first_member(team.id)).to eq(participant1)
      end
    end

The submitted_files(path = self.path) Method

This function in assignment_team.rb returns a list of files belonging to a team for a given path.

The unit test checks if the response is equal to the list of files that the team has submitted.

  describe "#submitted_files" do
    context "given a path" do
      it "returns submitted files" do
        files = ["file1.rb"]
        path = "assignment_path/5"
        allow(team).to receive(:path).with(path)
        allow(team).to receive(:files).with(path).and_return(files)
        expect(team.submitted_files(path)).to match_array(files)
      end
    end
  end

The self.import(row, assignment_id, options) Method

This function in assignment_team.rb imports a team if the team with the given id is present.

The unit test checks if the team is not imported if the team with the given id is not already present.

   context "when an assignment team does not already exist with the same id" do
     it "cannot be imported" do
       assignment_id = 1
       allow(Assignment).to receive(:find_by).with(id: assignment_id).and_return(nil)
       error_message = "The assignment with the id \"" + assignment_id.to_s + "\" was not found. <a href='/assignment/new'>Create</a> this assignment?"
       expect { AssignmentTeam.import([], assignment_id, []) }.
         to raise_error(ImportError, error_message)
     end
   end


The unit test checks if the team is imported if the team with the given id is already present.

   context "when an assignment team with the same id already exists" do
     it "gets imported through Team.import" do
       row = []
       assignment_id = 1
       options = []
       allow(Assignment).to receive(:find_by).with(id: assignment_id).and_return(assignment)
       allow(Team).to receive(:import).with(row, assignment_id, options, instance_of(AssignmentTeam))
       expect(Team).to receive(:import).with(row, assignment_id, options, instance_of(AssignmentTeam))
       AssignmentTeam.import(row, assignment_id, options)
     end
   end

The self.export(csv, parent_id, options) Method

This function in assignment_team.rb exports an instance of class AssignmentTeam

The unit test checks if the response is a new instance of the class AssignmentTeam or not.

  describe ".export" do
    it "redirects to Team.export with a new AssignmentTeam object" do
      allow(Team).to receive(:export).with([], 1, [], instance_of(AssignmentTeam))
      expect(Team).to receive(:export).with([], 1, [], instance_of(AssignmentTeam))
      AssignmentTeam.export([], 1, [])
    end
  end

The copy(course_id) Method

This function in assignment_team.rb copies the current assignment team and creates a new CourseTeam instance from the given team. It takes as parameter the id of the course assignment team has.

The test grabs the course id from the assignment team and calls the function to check if a course team is successfully created or not. The function returns and empty array, otherwise returns nil if the course team is not created successfully.

    context "for given assignment team" do
      it "copies the assignment team to course team" do
        assignment = team.assignment
        course = assignment.course
        expect(team.copy(course.id)).to eq([])
      end
    end

The add_participant(assignment_id, user) Method

This function in assignment_team.rb adds the user to the AssignmentTeam for the given assignment.

The unit test creates a user, and passes the user, assignment id for the team to the function and expects the result to be an instance of AssignmentParticipant meaning the user was successfully added as a participant.

    context "when a user is not a part of the team" do
      it "adds the user to the team" do
        user = build(:student, id: 10)
        assignment = team.assignment
        expect(team.add_participant(assignment.id, user)).to be_an_instance_of(AssignmentParticipant)
      end
    end


The unit test creates a user, adds him as a participant to the team, and passes the user, assignment id for the team to the function and expects the result to be nil as the user already exists as participant of the team.

    context "when a user is already a part of the team" do
      it "returns without adding user to the team" do
        allow(team).to receive(:users).with(no_args).and_return([user1])
        allow(AssignmentParticipant).to receive(:find_by).with(user_id: user1.id, parent_id: team.parent_id).and_return(participant1)
        assignment = team.assignment
        expect(team.add_participant(assignment.id, user1)).to eq(nil)
      end
    end

The scores(questions) Method

This function in assignment_team.rb returns the total score of the team. This function calculates scores for a given hash of questions.

The unit test checks if the response is equal to the score.

  describe "#scores" do
    context "when a hash of question is given" do
      it "returns the score received by the team" do
        questionnaire1 = build(:questionnaire, id: 1)
        questionnaire2 = build(:questionnaire, id: 2)

        question1 = build(:question, id: 1, questionnaire: questionnaire1)
        question2 = build(:question, id: 2, questionnaire: questionnaire2)
        questions = {questionnaire1.symbol => [question1], questionnaire2.symbol => [question2]}

        scores = {}
        scores[:team] = team
        scores[:questionnaire1] = {}
        scores[:questionnaire1][:assessments] = review_response_map
        scores[:questionnaire1][:scores] = 5
        scores[:questionnaire2] = {}
        scores[:questionnaire2][:assessments] = review_response_map
        scores[:questionnaire2][:scores] = 5
        scores[:total_score] = 10

        allow(team.assignment).to receive(:questionnaires).with(no_args).and_return([questionnaire1, questionnaire2])
        allow(ReviewResponseMap).to receive(:where).with(reviewee_id: team.id).and_return(review_response_map)
        allow(Answer).to receive(:compute_scores).with(scores[:questionnaire1][:assessments], questions[:questionnaire1]).and_return(5)
        allow(Answer).to receive(:compute_scores).with(scores[:questionnaire2][:assessments], questions[:questionnaire2]).and_return(5)
        allow(questionnaire1).to receive(:symbol).with(no_args).and_return(:questionnaire1)
        allow(questionnaire2).to receive(:symbol).with(no_args).and_return(:questionnaire2)
        allow(team.assignment).to receive(:compute_total_score).with(scores.except(:total_score)).and_return(10)
        expect(team.scores(questions)).to eq(scores)
      end
    end
  end

The files(directory) Method

This method in assignment_team.rb successfully returns a list of files in any given directory path, not just the assignment teams directory path.

The unit test assigns the directory path as given below and checks if the list of files returned contains a specific file which is known to be in the list.

    context "when file is present in the directory" do
      it "provides the list of files in directory and checks if file is present" do
        directory = "spec/models"
        expect(team.files(directory)).to include("spec/models/assignment_team_spec.rb")
      end
    end

The unit test assigns the directory path as given below and checks if the list of files returned does not contain a specific file which is known to not be in the list.

    context "when file is not present in the directory" do
      it "provides the list of files in directory and checks if file is not present" do
        directory = "spec/controllers"
        expect(team.files(directory)).not_to include("spec/models/assignment_team_spec.rb")
      end
    end

The submit_hyperlink(hyperlink) Method

This function in assignment_team.rb submits the hyperlink for the team if it is valid or not empty.

The unit test checks if the hyperlink is empty and raises an error.

    context "when a hyperlink is empty" do
      it "causes an exception to be raised" do
        expect { team.submit_hyperlink("") }.to raise_error("The hyperlink cannot be empty!")
      end
    end

The unit test checks if the hyperlink is invalid and raises a 404 error.

    context "when a hyperlink is invalid" do
      it "causes an exception to be raised with the proper HTTP status code" do
        invalid_hyperlink = "https://expertiza.ncsu.edu/not_a_valid_path"
        allow(Net::HTTP).to receive(:get_response).and_return("404")
        expect { team.submit_hyperlink(invalid_hyperlink) }.to raise_error("HTTP status code: 404")
      end
    end

The unit test checks if the the hyperlink has been submitted or not.

   context "when a valid hyperlink not in a certain improper format is submitted" do
     it "it is fixed and is saved to the database" do
       allow(team).to receive(:hyperlinks).and_return(["https://expertiza.ncsu.edu"])
       allow(team).to receive(:submitted_hyperlinks=)
       allow(team).to receive(:save)
       allow(Net::HTTP).to receive(:get_response).and_return("0")
       allow(YAML).to receive(:dump).with(["https://expertiza.ncsu.edu", "www.ncsu.edu"])
       expect(team).to receive(:submitted_hyperlinks=)
       expect(team).to receive(:save)
       expect(YAML).to receive(:dump).with(["https://expertiza.ncsu.edu", "http://www.ncsu.edu"])
       team.submit_hyperlink("www.ncsu.edu  ")
     end
   end
 end

The remove_hyperlink(hyperlink_to_delete) Method

This function in assignment_team.rb removes the hyperlink submitted by the team.

The unit test checks if the hyperlink has been deleted or not.

 describe "#remove_hyperlink" do
   context "when the hyperlink is in the assignment team's hyperlinks" do
     it "is removed from the team's list of hyperlinks" do
       allow(team).to receive(:hyperlinks).and_return(["https://expertiza.ncsu.edu", "https://www.ncsu.edu"])
       expect(team).to receive(:submitted_hyperlinks=)
       expect(team).to receive(:save)
       expect(YAML).to receive(:dump).with(["https://expertiza.ncsu.edu"])
       team.remove_hyperlink("https://www.ncsu.edu")
     end
   end
 end

The self.team(participant) Method

This function in assignment_team.rb returns the AssingmentTeam object that the given participant belongs to.

The unit test first builds a user and assigns that user as a participant1 of the team. Then allows that participant to be added as a team user and finally search the team for the given team user and expect the team for participant1 to be equal to the AssignmentTeam.

    context "when there is a participant" do
      it "provides the team for participant" do
        teamuser = build(:team_user, id: 1, team_id: team.id, user_id: user1.id)
        allow(team).to receive(:users).with(no_args).and_return([user1])
        allow(AssignmentParticipant).to receive(:find_by).with(user_id: user1.id, parent_id: team.parent_id).and_return(participant1)
        allow(TeamsUser).to receive(:where).with(user_id: participant1.user_id).and_return([teamuser])
        allow(Team).to receive(:find).with(teamuser.team_id).and_return(team)
        expect(AssignmentTeam.team(participant1)).to eq(team)
      end
    end

The self.export_fields(options) Method

This function in assignment_team.rb exports the fields for a given set of options. It always returns a list containing ["Team Name", "Assignment Name"] if the given set of options have a team name set to true.

The unit test expects the method to return a static list of strings for the team passed as options, with the team having a name.

    context "when team has name" do
      it "exports the fields" do
        expect(AssignmentTeam.export_fields(team)).to eq(["Team Name", "Assignment Name"])
      end
    end

The self.remove_team_by_id(id) Method

This function in assignment_team.rb removes team by using its id.

The unit test deletes the team by using its id and checks if return value is the instance of class AssignmentTeam.

  describe ".remove_team_by_id" do
    context "when a team has an id" do
      it "delete the team by id" do
        allow(AssignmentTeam).to receive(:find).with(team.id).and_return(team)
        expect(AssignmentTeam.remove_team_by_id(team.id)).to eq(team)
      end
    end
  end

The path Method

This function in assignment_team.rb returns a path to the directory where the team has submitted their files.

The unit test checks if the response is equal to the path belonging to the team's submission.

  describe "#path" do
    it "returns the path" do
      allow(team).to receive_message_chain(:assignment, :path).and_return("assignment_path")
      allow(team).to receive(:directory_num).and_return(5)
      expect(team.path).to eq "assignment_path/5"
    end
  end

The set_student_directory_num Method

This function in assignment_team.rb sets the directory number for the team.

The unit test checks if the directory number for the team has been set. The test will check if the return value is equal to true.

  describe "#set_student_directory_num" do
    it "sets the directory for the team" do
      team = build(:assignment_team, id: 1, parent_id: 1, directory_num: -1)
      max_num = 0
      allow(AssignmentTeam).to receive_message_chain(:where, :order, :first, :directory_num).
        with(parent_id: team.parent_id).with(:directory_num, :desc).with(no_args).with(no_args).and_return(max_num)
      expect(team.set_student_directory_num).to be true
    end
  end

The received_any_peer_review? Method

This function in assignment_team.rb checks if the team has received any peer reviews.

The unit test checks if any instance of ReviewResponseMap is present for the team.

  describe "#received_any_peer_review?" do
    it "checks if the team has received any reviews" do
      allow(ResponseMap).to receive_message_chain(:where, :any?).with(reviewee_id: team.id, reviewed_object_id: team.parent_id).with(no_args).and_return(true)
      expect(team.received_any_peer_review?).to be true
    end
  end

Test Design

We used multiple RSpec-specific and general industry best practices for testing in our unit tests. A description of our use of some of these techniques follows.

Test Structure

We exploited describe blocks in RSpec to give our unit tests a readable structure. We had describe blocks for each method of AssignmentTeam. In these blocks, we used RSpec context blocks where appropriate and examples to identify and test the different cases in the source code.

Stubs for Isolating the UUT

In order to isolate the unit under test (UUT), i.e. the current method under test, we employed RSpec stubs. We stubbed out database calls and methods that triggered them in order to improve the run time of the unit tests, and we also stubbed out some outgoing command and query messages in order to test only the current method under test.

DRY Testing Practices

We exploited factories and the RSpec helper method let to make our unit tests shorter, more readable, and more maintainable, or, in essence, DRYer. Using the FactoryBot factories in factories.rb allowed us to use real Expertiza objects in our unit tests. We used the FactoryBot.build method to make our objects in order to avoid the run time penalty of saving (and this later having to retrieve) objects from the database. We used the let method with the factory as in the example below to make lazy-allocated objects which are cached throughout each example, improving run time.

let(:team) { build(:assignment_team, id: 1, parent_id: 1) }

Whenever team is called in an example, it returns the same value, i.e. the same object, which essentially allows tests that need it to have access to the same object without the need for allocating it as a class variable or instance variable for all of the tests or some of the tests. We placed these statements outside the scope of our unit test examples so the same objects could be used throughout all of them, DRYing out the test code.

Testing Framework

RSpec Introduction

RSPEC is a testing framework for Ruby for behavior-driven development (BDD) licensed under MIT. It is inspired by JBehave and contains fully integrated JMock based framework which has a very rich and powerful DSL (domain-specific language) which resembles a natural language specification. Composed of multiple libraries structured to work together, RSpec provides encapsulated testing via the describe block to specify the behavior of the class and the context for the unit test case.

Why RSpec?

RSpec is easy to learn and implement and can be used with other testing tools like Cucumber and Minitest independently. It is extremely powerful for testing states with complicated setup and also helps in tearing down complex code to access the objects required for testing RSpec semantics encourage agile thinking and practice and it structures the tests in a more intuitive way.

Bugs Fixed

During testing of the AssignmentTeam model, we found two bugs, one in AssignmentTeam#submit_hyperlink and one in AssignmentTeam.import. In AssignmentTeam#submit_hyperlink, http:// was appended to hyperlink strings without this tag or https:// at the start of them, rather than prepended. We fixed this error in our pull request. In AssignmentTeam.import, id, an undefined parameter, was referenced while raising an exception, and we updated it to assignment_id, which was the actual name of the parameter passed to the method.

Results

The model has a total of 237 lines, out of which 127 were relevant lines. We achieved 100% coverage with avg hits per line at 2.3 (the number of times each specific line was run during the test suite)

Future Work

DRY principle could be cleaned up a little more. Sometimes it became necessary to use the let or build helper methods in each unit test. They may all be consolidated at the beginning.
Some more edge cases could be thought of for each method, and tests written for those. Although, we have tried to cover as many as possible.
Additionally, in the future, the code may also be refactored with a more efficient tool.
Test coverage through quality tests consistently added throughout the development of the Expertiza project should be the future work of the project.

References

1. Expertiza
2. Expertiza Wikipedia
3. Expertiza Github Repository
4. RSpec - Relish
5. Engineering Software as a Service