CSC/ECE 517 Spring 2025 - E2529. Testing for the Questionnaire Helper in Expertiza
This page describes the changes made for the Spring 2025 E2529. Testing for the Questionnaire Helper in Expertiza
Project Overview
Problem Statement
Our project focuses on developing test cases for the questionnaire_helper module within Expertiza, an open-source assignment and project management system built using Ruby on Rails. The primary aim of this project is to create thorough test plans and improve code coverage for these helper modules, ensuring their functionality and dependability within the Expertiza system.
Objectives
- Develop test plans/scenarios for questionnaire_helper.rb
- Improve code coverage for questionnaire_helper.rb
Files Involved
- Questionnaire_helper.rb: /app/helpers/questionnaire_helper.rb
Mentor
- Aniruddha Rajenkar (aarajenkar@ncsu.edu)
Team Members
- Priya Gandhi (pgandhi4@ncsu.edu)
- Saisumanth Tallapragada (stallap@ncsu.edu)
- Ishani Rajput (irajput@ncsu.edu)
Class and Method Overview
QuestionnaireHelper
In Expertiza, the QuestionnaireHelper module plays a key role in managing questionnaires by providing methods to handle advice sizing, question updates, and questionnaire creation based on type. It also contains constants that assist in executing these tasks. These methods contribute to the smooth handling of questionnaire functionalities across the application. Below are the constants and methods:
Constants
CSV_QUESTION, CSV_TYPE, CSV_PARAM, CSV_WEIGHT
- These constants serve as index references for different fields in a CSV row related to questionnaire data, such as the question text, type, parameters, and weight.
QUESTIONNAIRE_MAP
- A constant hash that maps string identifiers of questionnaire types to their respective Ruby class names. - Enables dynamic creation of questionnaire objects based on type strings. - Used by the questionnaire_factory method to instantiate the appropriate questionnaire class. - Helps avoid repetitive conditional logic by centralizing type-to-class associations. - Example mapping: 'ReviewQuestionnaire' => ReviewQuestionnaire
Methods
1. adjust_advice_size(questionnaire, question)
- Ensures that for each valid score in a ScoredQuestion, there exists exactly one QuestionAdvice. It removes extra or invalid advice entries and adds missing ones. - Parameters: - `questionnaire`: An instance of a questionnaire that contains scoring boundaries (min_question_score and max_question_score) - `question`: An instance of ScoredQuestion for which advice needs to be validated and adjusted. - Functionality: - Checks if the question is a ScoredQuestion. - Retrieves the min and max possible scores for the questionnaire. - Deletes any QuestionAdvice entries for this question that are outside the score bounds. - Iterates through all valid scores in the range [min, max]: - Adds a new QuestionAdvice if none exists for that score. - Deletes extra advice entries if more than one exists for the same score.
2. update_questionnaire_questions
- Updates the attributes of questions in a questionnaire based on form parameters. It only updates attributes whose values have actually changed. - Functionality: - Returns early if params[:question] is nil. - Iterates through each question ID (k) and corresponding attributes (v) in params[:question]. - Finds the Question object by ID. - For each attribute: - If the current value differs from the new value, updates the attribute. - Saves the updated question to the database.
3. questionnaire_factory(type)
- Creates a new instance of a questionnaire based on its type string using a predefined mapping. - Parameters: - `type`: A String representing the name of the questionnaire type (e.g., "ReviewQuestionnaire") - Functionality: - Looks up the class constant from the QUESTIONNAIRE_MAP using the given type string. - If no match is found, sets a flash error (flash[:error]). - If a match is found, returns a new instance of the corresponding questionnaire class.
Objective : Develop code testing scenarios for questionnaire_helper
- Dependency Injection Principle (DIP)
The QuestionnaireHelper methods accept objects like questionnaire or scored_question as parameters rather than instantiating them internally. This reflects the Dependency Injection Principle, promoting loose coupling. It also makes the methods easier to test and reuse, since dependencies are provided externally and can be mocked or substituted as needed.
- Single Responsibility Principle (SRP)
Each method in the QuestionnaireHelper module demonstrates the Single Responsibility Principle by handling a specific, well-defined task. For instance, the adjust_advice_size method is solely focused on resizing the advice fields based on score ranges, while the questionnaire_factory method exclusively deals with instantiating questionnaire objects depending on their type. This separation of concerns enhances both the clarity and maintainability of the code.
- Open/Closed Principle (OCP)
Although not immediately visible in limited code snippets, the design follows the Open/Closed Principle by being open to extension but closed to modification. For example, to support new questionnaire types, developers can extend the logic in questionnaire_factory without altering the existing structure, making the system more scalable and adaptable to future changes.
- Factory Method Pattern
The questionnaire_factory method resembles the Factory Method Pattern by generating different types of questionnaire instances based on input parameters. This pattern enables the dynamic creation of objects without hard-coding their specific classes, improving flexibility and extensibility.
- Strategy Pattern
Another applicable pattern is the Strategy Pattern, especially relevant if the behavior of questionnaires (e.g., scoring, rendering, or validation) varies by type. If such behaviors are encapsulated in interchangeable classes and selected at runtime (possibly via the factory method), it aligns well with the Strategy Pattern. This promotes code reusability and simplifies the addition of new behavior types without impacting existing logic.
After reviewing the QuestionnaireHelper class, we identified three methods in the class that require testing. These methods are as follows:
- adjust_advice_size
- questionnaire_factory
- update_questionnaire_questions
adjust_advice_size
Method Description
The adjust_advice_size method ensures that every ScoredQuestion has exactly one QuestionAdvice for each score in the range defined by its parent questionnaire. It removes advice entries outside the valid range and eliminates duplicates, while also creating missing entries for scores within range.
Test Setup
- Create a mock or real Questionnaire instance with:
min_question_score = 1 max_question_score = 5
- Create a ScoredQuestion with:
id assigned question_advices association
- Populate the QuestionAdvice table with:
Advice entries within the valid range Advice entries outside the valid range Duplicate advice entries for the same score
- Stub or use an in-memory DB to test without affecting real data.
Test Contexts
- Context 1: question is not a ScoredQuestion
Should perform no changes.
- Context 2: question is a ScoredQuestion, with:
Valid advice range entries → should remain untouched. Advice with scores outside [min, max] → should be deleted. Duplicate advice entries → should be reduced to one. Missing advice entries within valid range → should be created.
Expectations
- In Context 1, the method should exit early and make no database changes.
- In Context 2:
Only one QuestionAdvice should exist for each score in [min, max]. No QuestionAdvice should exist with scores outside [min, max]. All newly created QuestionAdvice entries should have the correct score and question_id. The total number of QuestionAdvice entries for the question should equal (max - min + 1).
Code Snippet
let(:questionnaire) { double('Questionnaire', min_question_score: 1, max_question_score: 3) }
let(:question) { double('ScoredQuestion', id: 101, question_advices: []) }
before do
allow(question).to receive(:is_a?).with(ScoredQuestion).and_return(true)
end
context 'when question is not a ScoredQuestion' do
it 'does not perform any operations' do
# Ensures no operations are performed for non-scored questions.
non_scored_question = double('Question')
expect(non_scored_question).to receive(:is_a?).with(ScoredQuestion).and_return(false)
expect(QuestionAdvice).not_to receive(:delete)
QuestionnaireHelper.adjust_advice_size(questionnaire, non_scored_question)
end
end
context 'when question is a ScoredQuestion' do
it 'deletes advice entries outside valid score range' do
# Verifies advice entries outside the valid score range are deleted.
expect(QuestionAdvice).to receive(:delete).with(
['question_id = ? AND (score > ? OR score < ?)', question.id, 3, 1]
)
allow(QuestionAdvice).to receive(:where).and_return([])
QuestionnaireHelper.adjust_advice_size(questionnaire, question)
end
end
questionnaire_factory
Method Description
The questionnaire_factory method is responsible for dynamically creating a new instance of a questionnaire based on a type string provided as input. It uses the QUESTIONNAIRE_MAP constant to resolve the correct class. If the type is not recognized, it sets an error message in the flash hash and returns nil.
Test Setup
- Ensure QUESTIONNAIRE_MAP is correctly defined with mappings like:
{ "ReviewQuestionnaire" => ReviewQuestionnaire, "SurveyQuestionnaire" => SurveyQuestionnaire }
- Stub or create minimal versions of the questionnaire classes (ReviewQuestionnaire, etc.) that support .new.
- Mock the flash hash to observe changes when the type is invalid.
Test Contexts
- Context 1: Valid questionnaire type is provided (e.g., "ReviewQuestionnaire").
Should return a new instance of ReviewQuestionnaire.
- Context 2: Invalid questionnaire type is provided (e.g., "InvalidType").
Should set an error message in flash[:error]. Should return nil.
- Context 3: type is nil or an empty string.
Should be treated as an invalid type. Should set flash error and return nil.
Expectations
- In Context 1:
A new instance of the correct class is returned. No flash error is set.
- In Context 2:
flash[:error] is set to 'Error: Undefined Questionnaire'. The method returns nil.
- In Context 3:
Same expectations as Context 2 (error set, return nil).
Code Snippet
before do
extend QuestionnaireHelper
end
it 'returns the correct questionnaire instance for a valid type' do
# Ensures the correct questionnaire instance is returned for valid types.
instance = questionnaire_factory('ReviewQuestionnaire')
expect(instance).to be_a(ReviewQuestionnaire)
end
it 'sets flash error and returns nil for an invalid type' do
# Verifies flash error is set and nil is returned for invalid types.
flash_hash = {}
allow(self).to receive(:flash).and_return(flash_hash)
result = questionnaire_factory('InvalidType')
expect(result).to be_nil
expect(flash_hash[:error]).to eq('Error: Undefined Questionnaire')
end
update_questionnaire_questions
Method Description
The update_questionnaire_questions method updates existing Question records with new attribute values received from the request parameters. It ensures that only the changed attributes are updated and saved, thereby optimizing database writes.
Test Setup
- Define params[:question] as a hash with:
{ "1" => { "txt" => "Updated question text", "weight" => "2" }, "2" => { "txt" => "Another text", "weight" => "1" } }
- Create corresponding Question objects with:
IDs 1 and 2 Initial attributes set to different values or the same (for testing updates vs no-ops)
- Stub or set up the params object inside a test controller or context.
Test Contexts
- Context 1: params[:question] is nil
Method should return immediately and make no changes.
- Context 2: params[:question] contains valid data
Some attributes differ from the existing Question values → should update and save. Some attributes match existing values → should not update those fields.
- Context 3: Mixed changes
One Question has all new values → should be updated and saved. One Question has no changed values → should not call save.
Expectations
- In Context 1, no Question records should be loaded or updated.
- In Context 2:
Each Question should receive updated values only for attributes that differ. question.save should be called only if at least one attribute is updated.
- In Context 3:
Only Question objects with modified attributes should trigger a .save call. Attributes already matching current values should not be re-assigned.
Code Snippet
before do
extend QuestionnaireHelper
@question1 = double('Question', id: 1)
@question2 = double('Question', id: 2)
allow(Question).to receive(:find).with("1").and_return(@question1)
allow(Question).to receive(:find).with("2").and_return(@question2)
end
it 'returns early if params[:question] is nil' do
# Ensures method exits early when no questions are provided.
allow(self).to receive(:params).and_return({})
expect(@question1).not_to receive(:save)
update_questionnaire_questions
end
it 'updates all changed fields and saves the question' do
# Ensures all changed fields are updated and saved.
allow(@question1).to receive(:send).with("txt").and_return("old")
allow(@question1).to receive(:send).with("weight").and_return("1")
expect(@question1).to receive(:send).with("txt=", "new")
expect(@question1).to receive(:send).with("weight=", "2")
expect(@question1).to receive(:save)
allow(self).to receive(:params).and_return({
question: {
"1" => { "txt" => "new", "weight" => "2" }
}
})
update_questionnaire_questions
end
Coverage Results
Questionnaire_helper
- Previous coverage: 42.86%
- Current coverage: 100.00%
Conclusion
This document outlines the approach taken to improve test coverage for the questionnaire_helper.rb file in Expertiza. Test cases were developed for all key methods, including adjust_advice_size, update_questionnaire_questions, and questionnaire_factory. With these additions, the file now has full method coverage. The focus going forward will be to maintain this coverage and ensure that any future changes to the module are accompanied by corresponding tests.
Links
- Link to Expertiza repository: here
- GitHub Pull Request: Pull Request
- GitHub Repository: Github
- Demo Video: Demo
References
1. Expertiza on GitHub (https://github.com/expertiza/expertiza)
2. The live Expertiza website (http://expertiza.ncsu.edu/)