DEV Community

Prasiddh Naik
Prasiddh Naik

Posted on

How I built a counter program in Anchor and learned to trust my tests

The counter program is small, but the useful part was not the counter. The useful part was learning where state lives, how Anchor turns account validation into Rust types, and how a test suite can prove that a permission check is actually doing work.

Here is the first piece that made Anchor feel different from a normal Web2 backend:

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = authority, space = 8 + Counter::INIT_SPACE)]
    pub counter: Account<'info, Counter>,
    #[account(mut)]
    pub authority: Signer<'info>,
    pub system_program: Program<'info, System>,
}
Enter fullscreen mode Exit fullscreen mode

In a Web2 handler, I am used to receiving a request body and then deciding what database rows to read or write. In Anchor, the accounts are part of the request. This struct says the counter account should be created, the authority signer pays for that account, and the System Program is available because account creation needs it. The space value reserves enough bytes for Anchor's discriminator plus the fields in my Counter account.

The account itself is tiny: it stores the wallet allowed to update the counter and the current number.

The initialize handler is short because the account plumbing already happened before the body runs:

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    let counter = &mut ctx.accounts.counter;
    counter.authority = ctx.accounts.authority.key();
    counter.count = 0;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

ctx.accounts gives me typed access to the accounts from the Initialize struct. At this point, Anchor has already checked that authority signed, that the counter account can be initialized, and that the right programs are present. My code only has to write the starting state.

The second instruction increments the counter. This is the important part:

pub struct Increment<'info> {
    #[account(mut, has_one = authority)]
    pub counter: Account<'info, Counter>,
    pub authority: Signer<'info>,
}
Enter fullscreen mode Exit fullscreen mode

The key line is has_one = authority. It tells Anchor to compare the authority field stored inside the Counter account with the signer passed into this instruction. If they do not match, the handler body never gets to run.

That was the permission check. The next question was whether my tests could prove it.

The Happy Path

The first test creates a fresh LiteSVM instance, initializes a counter, increments it, then reads the account back. The whole point is this final check:

assert_eq!(parsed.count, 1);
assert_eq!(parsed.authority, authority.pubkey());
Enter fullscreen mode Exit fullscreen mode

This test would fail if initialize stored the wrong authority, if increment changed the wrong amount, or if the account could not be deserialized back into Counter state.

But happy paths are not enough.

If I only test that the right wallet can increment, I have not proved that the wrong wallet cannot. That is the Solana version of testing that an admin endpoint works but never testing that a non-admin gets rejected.

The Failure Path

So I added a test where authority_a creates the counter and authority_b tries to increment it. The important assertion is:

assert!(
    result.is_err(),
    "increment should fail when signed by the wrong authority"
);
Enter fullscreen mode Exit fullscreen mode

This test would fail if I accidentally removed the has_one = authority constraint or otherwise allowed any signer to increment someone else's counter.

That sentence is the whole reason the test exists.

Breaking It On Purpose

The most useful thing I did was break the program intentionally.

For one experiment, I changed the increment logic from "add one" to "add two":

.checked_add(2)
Enter fullscreen mode Exit fullscreen mode

The transaction still succeeded, but the test caught the wrong state:

thread 'initialize_then_increment' panicked at programs/counter/tests/counter.rs:86:5:
assertion `left == right` failed
  left: 2
 right: 1
Enter fullscreen mode Exit fullscreen mode

That is the kind of failure I want. It is clear, specific, and tied directly to the behavior I care about.

I also removed has_one = authority once. Before the failure test existed, the suite stayed green. After the failure test existed, the suite failed exactly where it should:

increment should fail when signed by the wrong authority
Enter fullscreen mode Exit fullscreen mode

That was the moment the permission check stopped being a line I trusted and became a line I could audit by running tests.

What I Learned

Anchor programs are small on the surface, but a lot happens before a handler body runs. The account structs are not just TypeScript-style types or documentation. They are validation rules. They decide which accounts are writable, which accounts must sign, which accounts get initialized, and which relationships must already be true.

I also learned that a green test suite only means something if it tries to prove the program wrong. The happy path proved the counter could work. The failure path proved the authority gate was real. The mutation experiments proved both assertions were load-bearing.

If I had another week, I would add one more failure test for double initialization, tighten the failure assertions to check the exact Anchor error, and then move the counter account to a PDA so the client does not have to create a random keypair for it.

Small program. Real lesson.

Top comments (0)