This content originally appeared on Level Up Coding - Medium and was authored by Lara Taiwo
Creating a simple voting contract, a deployment script file and a test file for testing both voting and deployment files.
Introduction
I’ve always shied away from testing, but as I’ve progressed in smart contract development, I’ve realized just how essential it is.
Lately, I’ve been diving deep into Foundry’s testing functionality, working to improve and boost my test coverage to over 95%.
In this article, I’m excited to share my journey of building and testing a decentralized voting system using Solidity.
I’ll walk you through three core files: the Voting smart contract, the deployment script, and the test file.
My aim is to provide a clear breakdown of each file so that you can follow along, even if you’re just starting out, like me!
1. The Voting Smart Contract(Voting.sol)
This is the heart of the voting system. The Voting contract defines all the functionalities required for adding candidates, casting votes, and determining the winner. Here's the code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Voting {
error Voting__onlyOwner();
error Voting__VotingEnded();
error Voting__alreadyVoted();
error Voting__invalidCandidateIndex();
error Voting__NoCandidateAvailable();
address private immutable i_owner;
uint256 public immutable i_votingEnded;
uint256 public totalVotes;
mapping(address => bool) public hasVoted;
struct Candidate {
string name;
string partyName;
uint256 voteCount;
}
// Mapping for candidates and counter for keeping track of candidates
mapping(uint256 => Candidate) public candidates;
uint256 public candidateCount;
constructor(uint256 _votingDuration) {
i_owner = msg.sender;
i_votingEnded = block.timestamp + _votingDuration;
}
event CandidateAdded(string name, string partyName);
event VoteCasted(address voter, uint256 candidateIndex);
modifier onlyOwner() {
require(msg.sender == i_owner, Voting__onlyOwner());
_;
}
modifier votingTime() {
require(block.timestamp < i_votingEnded, Voting__VotingEnded());
_;
}
function addCandidate(string memory _name, string memory _partyName) public onlyOwner {
candidates[candidateCount] = Candidate(_name, _partyName, 0);
candidateCount++;
emit CandidateAdded(_name, _partyName);
}
function vote(uint256 candidateIndex) public votingTime {
require(!hasVoted[msg.sender], Voting__alreadyVoted());
require(candidateIndex < candidateCount, Voting__invalidCandidateIndex());
hasVoted[msg.sender] = true;
candidates[candidateIndex].voteCount += 1;
totalVotes += 1;
emit VoteCasted(msg.sender, candidateIndex);
}
function getWinner() public view returns (string memory winnerName, string memory winnerParty, uint256 winnerVoteCount) {
require(candidateCount > 0, Voting__NoCandidateAvailable());
uint256 winningVoteCount = 0;
uint256 winningIndex = 0;
for (uint256 i = 0; i < candidateCount; i++) {
if (candidates[i].voteCount > winningVoteCount) {
winningVoteCount = candidates[i].voteCount;
winningIndex = i;
}
}
Candidate memory winner = candidates[winningIndex];
return (winner.name, winner.partyName, winner.voteCount);
}
function getCanditateIndex(uint256 index) public view returns (string memory, string memory, uint256) {
require(index < candidateCount, Voting__invalidCandidateIndex());
Candidate memory candidate = candidates[index];
return (candidate.name, candidate.partyName, candidate.voteCount);
}
function getCandidateCount() public view returns (uint256) {
return candidateCount;
}
function getRemainingVotingTime() public view returns (uint256) {
if (block.timestamp >= i_votingEnded) {
return 0;
}
return (i_votingEnded - block.timestamp);
}
function getTotalVotes() public view returns (uint256) {
return totalVotes;
}
function getVotePerCandidate(uint256 candidateIndex) public view returns (uint256) {
return candidates[candidateIndex].voteCount;
}
}
Here’s a detailed breakdown:
contract Voting {
error Voting__onlyOwner();
error Voting__VotingEnded();
error Voting__alreadyVoted();
error Voting__invalidCandidateIndex();
error Voting__NoCandidateAvailable();
Custom Errors: Custom errors are used instead of regular require statements because they save gas. For example, Voting__onlyOwner will be used to indicate that only the owner can perform a specific action.
address private immutable i_owner;
uint256 public immutable i_votingEnded;
uint256 public totalVotes;
- State Variables: The contract stores the owner’s address, the voting end time, and the total number of votes.
- i_owner is marked as immutable, meaning it can only be set once during contract deployment and will never change.
struct Candidate {
string name;
string partyName;
uint256 voteCount;
}
mapping(uint256 => Candidate) public candidates;
uint256 public candidateCount;
- Candidate Structure: Each candidate has a name, a party name, and a vote count.
- Candidates are stored in a mapping using their index number, and candidateCount tracks how many candidates have been added.
Constructor and Modifiers
constructor(uint256 _votingDuration) {
i_owner = msg.sender;
i_votingEnded = block.timestamp + _votingDuration;
}
The constructor sets the contract owner to the address that deploys the contract (msg.sender) and calculates when the voting period will end by adding the given duration to the current timestamp.
modifier onlyOwner() {
require(msg.sender == i_owner, Voting__onlyOwner());
_;
}
modifier votingTime() {
require(block.timestamp < i_votingEnded, Voting__VotingEnded());
_;
}
Modifiers: These are reusable pieces of code that can be attached to functions.
- onlyOwner: Restricts certain actions to the contract owner.
- votingTime: Ensures that certain actions (like voting) can only be done during the voting period.
Main Functions
function addCandidate(string memory _name, string memory _partyName) public onlyOwner {
candidates[candidateCount] = Candidate(_name, _partyName, 0);
candidateCount++;
emit CandidateAdded(_name, _partyName);
}
addCandidate: Only the contract owner can add candidates, and each candidate has a name and party affiliation.
function vote(uint256 candidateIndex) public votingTime {
require(!hasVoted[msg.sender], Voting__alreadyVoted());
require(candidateIndex < candidateCount, Voting__invalidCandidateIndex());
hasVoted[msg.sender] = true;
candidates[candidateIndex].voteCount += 1;
totalVotes += 1;
emit VoteCasted(msg.sender, candidateIndex);
}
vote: This function allows users to vote for a candidate. It checks if the user has already voted and ensures the vote is cast within the voting period.
function getWinner() public view returns (string memory winnerName, string memory winnerParty, uint256 winnerVoteCount) {
require(candidateCount > 0, Voting__NoCandidateAvailable());
uint256 winningVoteCount = 0;
uint256 winningIndex = 0;
for (uint256 i = 0; i < candidateCount; i++) {
if (candidates[i].voteCount > winningVoteCount) {
winningVoteCount = candidates[i].voteCount;
winningIndex = i;
}
}
Candidate memory winner = candidates[winningIndex];
return (winner.name, winner.partyName, winner.voteCount);
}
getWinner: This function returns the candidate with the highest vote count. It loops through all the candidates and compares their vote counts to find the winner.
2. Deployment Script (DeployVoting.s.sol)
The deployment script helps automate the process of deploying the Voting contract onto a blockchain.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script} from "forge-std/Script.sol";
import {Voting} from "../src/Voting.sol";
contract Deploy is Script {
uint256 public constant VOTING_DAYS = 5 days;
function run() external returns (Voting) {
vm.startBroadcast();
Voting voting = new Voting(VOTING_DAYS);
vm.stopBroadcast();
return voting;
}
}
run: This is the core function that deploys the Voting contract. It sets the voting duration to 5 days, and calls vm.startBroadcast() to begin broadcasting transactions to the blockchain, followed by vm.stopBroadcast() when the deployment is done.
3. Test File (TestVoting.t.sol)
This file tests different functionalities of the Voting contract. Here's the code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {Voting} from "../src/Voting.sol";
import {Deploy} from "../script/DeployVoting.s.sol";
contract testVote is Test {
uint256 public constant votingDays = 5 days;
Voting public voting;
Deploy public deploy;
function setUp() public {
voting = new Voting(votingDays);
deploy = new Deploy();
voting.addCandidate("Obama", "party1");
voting.addCandidate("Trump", "party2");
}
function test_addcandidate() public {
voting.addCandidate("Kamela", "party3");
assertEq(voting.getCandidateCount(), 3);
}
function test_getCandidateIndex() public {
(string memory name, string memory party, uint256 voteCount) = voting.getCanditateIndex(0);
assertEq(name, "Obama");
assertEq(party, "party1");
assertEq(voteCount, 0);
}
function test_vote() public {
vm.warp(block.timestamp + 4 days);
voting.vote(0);
vm.prank(address(0x1234));
voting.vote(1);
vm.prank(address(0x5678));
voting.vote(0);
assertEq(voting.getTotalVotes(), 3);
assertEq(voting.getVotePerCandidate(1), 1);
voting.getRemainingVotingTime();
voting.getWinner();
}
function test_onlyOwnerAddCandidate() public {
vm.prank(address(0x1234)); // Using address(0x1234) as a non-owner address
vm.expectRevert(Voting.Voting__onlyOwner.selector);
voting.addCandidate("NonOwnerCandidate", "party4");
}
function test_votingTime() public {
vm.warp(block.timestamp + 10 days);
vm.expectRevert(Voting.Voting__VotingEnded.selector);
voting.vote(0);
}
function test_remainingTime() public {
vm.warp(block.timestamp + 7 days);
voting.getRemainingVotingTime();
assertEq(voting.getRemainingVotingTime(), 0);
}
function test_getCandidateCount() public {
voting.getCandidateCount();
assertEq(voting.getCandidateCount(), 2);
}
function test_deployment() public {
voting = deploy.run();
// Check if the contract was deployed correctly
assertTrue(address(voting) != address(0), "Voting contract not deployed");
// Validate the voting duration is set correctly with a tolerance
uint256 votingDuration = voting.i_votingEnded();
uint256 currentTime = block.timestamp;
uint256 expectedVotingEnd = currentTime + 5 days;
// Allow a tolerance of ±2 seconds
assertTrue(votingDuration >= expectedVotingEnd - 2 && votingDuration <= expectedVotingEnd + 2, "Voting duration should be approximately 5 days");
}
}
Here’s how it works:
Imports
import {Test} from "forge-std/Test.sol";
import {Voting} from "../src/Voting.sol";
import {Deploy} from "../script/DeployVoting.s.sol";
- Test: This is a utility from Foundry that provides useful functions for writing tests.
- Voting: Imports your Voting contract to run tests against it.
- Deploy: Imports a script that handles deploying the contract during testing.
State Variables
uint256 public constant votingDays = 5 days;
Voting public voting;
Deploy public deploy;
- votingDays: The duration of the voting period (5 days) is defined here.
- voting: A variable that represents the instance of your Voting contract.
- deploy: An instance of the Deploy contract, used to deploy the Voting contract for testing.
setUp Function
function setUp() public {
voting = new Voting(votingDays);
deploy = new Deploy();
voting.addCandidate("Obama", "party1");
voting.addCandidate("Trump", "party2");
}
- setUp(): This function runs before each test. It sets up the environment by deploying the Voting contract and adding two initial candidates, "Obama" and "Trump."
- After this function is run, the tests can use the deployed contract with these candidates.
Test: test_addcandidate
function test_addcandidate() public {
voting.addCandidate("Kamela", "party3");
assertEq(voting.getCandidateCount(), 3);
}
Purpose: This test checks if you can add a candidate to the Voting contract.
Explanation: It adds a new candidate “Kamela” and then checks if the total number of candidates is now 3 (using assertEq, which checks if two values are equal).
- assertEq: Confirms that the candidate count has been incremented after adding the candidate.
Test: test_getCandidateIndex
function test_getCandidateIndex() public {
(string memory name, string memory party, uint256 voteCount) = voting.getCanditateIndex(0);
assertEq(name, "Obama");
assertEq(party, "party1");
assertEq(voteCount, 0);
}
Purpose: This test ensures that you can fetch the details of a candidate.
Explanation: It retrieves the candidate at index 0 (Obama) and checks that:
- The candidate’s name is “Obama.”
- The party is “party1.”
- The vote count is initially 0.
- assertEq: Confirms that the data returned from the contract matches the expected values.
Test: test_vote
function test_vote() public {
vm.warp(block.timestamp + 4 days);
voting.vote(0);
vm.prank(address(0x1234));
voting.vote(1);
vm.prank(address(0x5678));
voting.vote(0);
assertEq(voting.getTotalVotes(), 3);
assertEq(voting.getVotePerCandidate(1), 1);
voting.getRemainingVotingTime();
voting.getWinner();
}
Purpose: Tests the core voting functionality, ensuring votes are cast correctly and totals are updated.
Steps:
Advance Time: Uses vm.warp to move the blockchain's timestamp forward by 4 days, keeping it within the 5-day voting period.
Cast Votes:
- First Vote: Casts a vote for candidate 0 (“Obama”).
- Second Vote: Uses vm.prank to simulate a different user (0x1234) casting a vote for candidate 1 ("Trump").
- Third Vote: Simulates another user (0x5678) casting another vote for candidate 0 ("Obama").
Assert Vote Totals:
- Total Votes: Checks if the total votes are now 3.
- Candidate Votes: Ensures candidate 1 (“Trump”) has exactly 1 vote.
Additional Function Calls:
- Calls getRemainingVotingTime() and getWinner() to ensure these functions execute without issues after voting.
Explanation:
- vm.warp: Simulates the passage of time, essential for testing time-dependent functionalities.
- vm.prank: Mocks different users interacting with the contract, testing multi-user scenarios.
- Assertions: Confirms that voting increments the correct counters and that vote counts are accurate.
Test: test_onlyOwnerAddCandidate
function test_onlyOwnerAddCandidate() public {
vm.prank(address(0x1234)); // Simulate a non-owner address
vm.expectRevert(Voting.Voting__onlyOwner.selector);
voting.addCandidate("NonOwnerCandidate", "party4");
}
Purpose: Ensures that only the contract owner can add new candidates.
Steps:
- Simulate Non-Owner: Uses vm.prank to change the msg.sender to 0x1234, a non-owner address.
- Expect Revert: Sets up an expectation that the next operation will revert with the Voting__onlyOwner error.
- Attempt to Add Candidate: Tries to add a candidate “NonOwnerCandidate” from “party4”.
Explanation:
- Security Check: Verifies that the onlyOwner modifier correctly restricts access to the addCandidate function.
- Reversion: The test expects the transaction to fail, ensuring unauthorized users cannot modify the contract state.
Test: test_votingTime
function test_votingTime() public {
vm.warp(block.timestamp + 10 days);
vm.expectRevert(Voting.Voting__VotingEnded.selector);
voting.vote(0);
}
Purpose: Tests if voting fails after the voting period ends.
Explanation:
- vm.warp(block.timestamp + 10 days): Fast-forwards time by 10 days, past the 5-day voting period.
- vm.expectRevert(Voting.Voting__VotingEnded.selector): Checks that voting now reverts with the Voting__VotingEnded error because the voting period has ended.
Test: test_remainingTime
function test_remainingTime() public {
vm.warp(block.timestamp + 7 days);
voting.getRemainingVotingTime();
assertEq(voting.getRemainingVotingTime(), 0);
}
Purpose: Tests if the getRemainingVotingTime() function works after the voting period has ended.
Explanation:
- It fast-forwards time by 7 days (past the 5-day voting period) and calls getRemainingVotingTime().
- The function should return the remaining voting time correctly, even after the voting period has ended.
Test: test_getCandidateCount
function test_getCandidateCount() public {
voting.getCandidateCount();
assertEq(voting.getCandidateCount(), 2);
}
Purpose: This simple test checks if the getCandidateCount() function works correctly.
Explanation: The function retrieves the total number of candidates from the contract. In this case, it should return the correct count.
Test: test_deployment
function test_deployment() public {
voting = deploy.run();
assertTrue(address(voting) != address(0), "Voting contract not deployed");
uint256 votingDuration = voting.i_votingEnded();
uint256 currentTime = block.timestamp;
uint256 expectedVotingEnd = currentTime + 5 days;
assertTrue(votingDuration >= expectedVotingEnd - 2 && votingDuration <= expectedVotingEnd + 2, "Voting duration should be approximately 5 days");
}
Purpose: Tests the deployment of the Voting contract.
Explanation:
- deploy.run(): Runs the Deploy script to deploy the Voting contract.
- assertTrue(address(voting) != address(0)): Ensures that the Voting contract was deployed correctly (i.e., its address is not zero).
Checks the voting duration:
- It retrieves the voting end time (i_votingEnded).
- Compares it with the current time plus 5 days, allowing a tolerance of ±2 seconds due to slight variations during deployment.
4. Test Coverage Results
After running the tests, I achieved the following coverage for the Voting contract:
Ran 8 tests for test/TestVoting.t.sol:testVote
[PASS] test_addcandidate() (gas: 68113)
[PASS] test_deployment() (gas: 1075011)
[PASS] test_getCandidateCount() (gas: 7882)
[PASS] test_getCandidateIndex() (gas: 24645)
[PASS] test_onlyOwnerAddCandidate() (gas: 11054)
[PASS] test_remainingTime() (gas: 8995)
[PASS] test_vote() (gas: 171898)
[PASS] test_votingTime() (gas: 9684)
Suite result: ok. 8 passed; 0 failed; 0 skipped; finished in 77.80ms (46.80ms CPU time)
Ran 1 test suite in 80.52ms (77.80ms CPU time): 8 tests passed, 0 failed, 0 skipped (8 total tests)
| File | % Lines | % Statements | % Branches | % Funcs |
|---------------------------|-----------------|-----------------|----------------|-----------------|
| script/DeployVoting.s.sol | 100.00% (4/4) | 100.00% (5/5) | 100.00% (0/0) | 100.00% (1/1) |
| src/Voting.sol | 100.00% (31/31) | 100.00% (33/33) | 71.43% (10/14) | 100.00% (11/11) |
| Total | 100.00% (35/35) | 100.00% (38/38) | 71.43% (10/14) | 100.00% (12/12) |
Summary:
- 100% Line Coverage: All lines of code in both DeployVoting.s.sol and Voting.sol were executed during the tests.
- 100% Statement Coverage: All statements in both files were successfully covered.
- 71.43% Branch Coverage: Some conditional branches in the Voting.sol contract were not fully tested so I’m going to work more on this.
- 100% Function Coverage: Every function in the contracts was executed during testing.
Additional Resources
- Foundry Documentation: https://book.getfoundry.sh/
- Solidity Documentation: https://docs.soliditylang.org/
A Simple Voting System: Creating, Testing and Deploying using Foundry was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.
This content originally appeared on Level Up Coding - Medium and was authored by Lara Taiwo
Lara Taiwo | Sciencx (2024-09-27T02:00:24+00:00) A Simple Voting System: Creating, Testing and Deploying using Foundry. Retrieved from https://www.scien.cx/2024/09/27/a-simple-voting-system-creating-testing-and-deploying-using-foundry/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.