23 min read
Overview
Bankrun is a fast, powerful, and lightweight framework for testing Solana programs in NodeJS. It addresses several common developer pain points when testing Solana programs, which can save you time. Let's look at what it can do for you and how to start using it!
What You Will Do
- Learn about what Bankrun is and how it can help you test Solana programs
- Create a new Anchor program that depends on certain time and account-based constraints
- Write a test using Bankrun to verify the program's functionality
What You Will Need
- Basic understanding of Solana Fundamentals
- Experience with Solana's local validator (Guide: How to Set Up a Local Validator)
- Experience with Anchor (Guide: How to Write Your First Anchor Program)
- TypeScript and Rust experience
Dependency | Version |
---|---|
Solana cli | 1.18.8 |
Anchor CLI | 0.30.1 |
Node.js | latest |
yarn | latest |
ts-node | latest |
typescript | latest |
Rust | latest |
What is Bankrun?
Bankrun is an alternative to the Solana CLI test validator (run via solana-test-validator
) that is designed to be faster and more flexible. It offers several features that make it easier to test your Solana programs, including:
- faster test validation
- "time travel" to modify the state of the blockchain
- arbitrary accounts data (this can be helpful in utilizing token mints and other relevant accounts from mainnet)
Bankrun works by spinning up a lightweight BanksServer, which functions almost like a light RPC, and creating a BanksClient to talk to the server.
How to Use Bankrun?
Let's create a simple Anchor project that will allow us to test some of Bankrun's features. Before starting, double-check that you have installed Anchor version 0.30.1 or higher. You can check the version by running the following command:
anchor --version
If you do not, you can follow the installation instructions here.
Create a New Project
Go ahead and create a new project in your terminal. From the project parent directory, run the following command:
anchor init bankrun-test
And then change into the new directory:
cd bankrun-test
After the project is created, go ahead and build the program to make sure everything is working:
anchor build
Your initial build should complete successfully after a few minutes. If you see an error, follow the instructions in the error message to fix the issue.
Install Dependencies
We will need a few dependencies to make our project work. Let's install them:
First, let's install our Node.js dependencies:
yarn add solana-bankrun anchor-bankrun @solana/spl-token
This will allow us to use Bankrun in our tests along with the Solana Token Program.
Next, let's add the SPL token program to our Anchor Program. Navigate to programs/bankrun-test/Cargo.toml
and add anchor-spl
to the dependencies:
[dependencies]
anchor-lang = "0.30.1"
anchor-spl = "0.30.1"
Since we are using Anchor 0.30+, we will also need to update the idl-build
feature in programs/bankrun-test/Cargo.toml
to include the anchor-spl
dependency:
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
Great! We're now ready to start building our program.
Create Anchor Program
Since the focus of this guide is using Bankrun for testing, we will not spend much time on the Anchor program itself. Instead, we will focus on writing the Bankrun tests. Navigate to programs/bankrun-test/src/lib.rs
and replace the contents with the following (make sure to replace the program ID with your own):
use anchor_lang::prelude::*;
use anchor_spl::token::TokenAccount;
use std::str::FromStr;
declare_id!("11111111111111111111111111111111"); // REPLACE WITH YOUR PROGRAM ID
const MINIMUM_SLOT: u64 = 100;
const TOKEN_MINIMUM_BALANCE: u64 = 100_000_000_000;
const USDC_MINT: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
#[program]
pub mod bankrun_test {
use super::*;
pub fn set_data(ctx: Context<SetData>) -> Result<()> {
let current_slot = Clock::get()?.slot;
msg!("Current slot: {}", current_slot);
require_gte!(current_slot, MINIMUM_SLOT, BankrunError::InvalidSlot);
ctx.accounts.data_account.new_data = ctx.accounts.new_data.key();
ctx.accounts.data_account.last_updated_slot = current_slot;
msg!("Set new data: {}", ctx.accounts.new_data.key());
Ok(())
}
pub fn check_spl_token(ctx: Context<CheckSplToken>) -> Result<()> {
let usdc_mint = Pubkey::from_str(USDC_MINT).unwrap();
let token_account = &ctx.accounts.token_account;
let token_balance = token_account.amount;
msg!("Token account: {} has a balance of {}", token_account.key(), token_balance);
require_keys_eq!(token_account.mint, usdc_mint, BankrunError::InvalidTokenMint);
require_gte!(token_balance, TOKEN_MINIMUM_BALANCE, BankrunError::InsufficientTokenBalance);
Ok(())
}
}
#[derive(Accounts)]
pub struct SetData<'info> {
#[account(mut)]
pub payer: Signer<'info>,
#[account(
init,
payer = payer,
space = 8 + DataAccount::INIT_SPACE
)]
pub data_account: Account<'info, DataAccount>,
pub new_data: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct CheckSplToken<'info> {
pub token_account: Account<'info, TokenAccount>,
}
#[account]
#[derive(InitSpace)]
pub struct DataAccount {
pub last_updated_slot: u64,
pub new_data: Pubkey
}
#[error_code]
pub enum BankrunError {
// Error code: 6000
#[msg("Invalid slot")]
InvalidSlot,
// Error code: 6001
#[msg("Insufficient token balance")]
InsufficientTokenBalance,
// Error code: 6002
#[msg("Invalid token mint")]
InvalidTokenMint,
}
You can run anchor keys sync
to update your declare_id!
macro to match the program ID of your program.
Let's take a look at each of our program's instructions:
set_data
: This instruction sets thelast_updated_slot
field of theDataAccount
to the current slot, and sets thenew_data
field to the public key of thenew_data
account that signs the transaction. The instruction checks that the current slot is greater than or equal to theMINIMUM_SLOT
constant. If the current slot is less than theMINIMUM_SLOT
constant, the instruction will fail with the error codeInvalidSlot
.check_spl_token
: This instruction checks that thetoken_account
has a balance greater than or equal to theTOKEN_MINIMUM_BALANCE
constant. It also checks that thetoken_account
is associated with theUSDC_MINT
constant. If either of these conditions are not met, the instruction will fail with the error codeInsufficientTokenBalance
orInvalidTokenMint
, respectively.
The should be sufficient in helping us demonstrate Bankrun's ability to "time travel" and write arbitrary data to accounts.
Go ahead and run anchor build
to build the program and ensure that it compiles successfully. You should not see any errors, but if you do, follow the instructions in the terminal to fix them.
Write Tests
Alright! Let's get started writing our tests. Navigate to tests/bankrun-test.ts
and delete the existing contents.
Import Dependencies
Let's start by importing the necessary dependencies. At the top of the file, add the following:
import { setProvider, Program } from "@coral-xyz/anchor";
import { BankrunTest } from "../target/types/bankrun_test";
import {
AccountInfoBytes,
AddedAccount,
BanksClient,
BanksTransactionResultWithMeta,
ProgramTestContext,
startAnchor
} from "solana-bankrun";
import { BankrunProvider } from "anchor-bankrun";
import { expect } from "chai";
import {
PublicKey,
Transaction,
Keypair,
Connection,
clusterApiUrl,
TransactionInstruction
} from "@solana/web3.js";
import {
ACCOUNT_SIZE,
AccountLayout,
getAssociatedTokenAddressSync,
MintLayout,
TOKEN_PROGRAM_ID
} from "@solana/spl-token";
const IDL = require("../target/idl/bankrun_test.json");
Most of these should look familiar, but we are importing a few new dependencies from anchor-bankrun
and solana-bankrun
. We will cover these when we get to the setup functions.
Define Constants
Let's create a few constants to help us with our tests. Below your imports, add the following:
// Constants
const PROJECT_DIRECTORY = ""; // Leave empty if using default anchor project
const USDC_DECIMALS = 6;
const USDC_MINT_ADDRESS = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
const MINIMUM_SLOT = 100;
const MINIMUM_USDC_BALANCE = 100_000_000_000; // 100k USDC
PROJECT_DIRECTORY
: This is the directory where your Anchor project (same directory withAnchor.toml
) is located. If you are using the default project, you can leave this as an empty string.USDC_DECIMALS
: This is the number of decimals in the USDC token.USDC_MINT_ADDRESS
: This is the address of the USDC token mint.MINIMUM_SLOT
: This is the same minimum slot that we defined in our Anchor program.MINIMUM_USDC_BALANCE
: This is the same minimum balance of USDC that we defined in our Anchor program.
Setup Functions
Next, let's create some setup functions to help us with our tests. Below your constants, add the following:
async function createAndProcessTransaction(
client: BanksClient,
payer: Keypair,
instruction: TransactionInstruction,
additionalSigners: Keypair[] = []
): Promise<BanksTransactionResultWithMeta> {
const tx = new Transaction();
const [latestBlockhash] = await client.getLatestBlockhash();
tx.recentBlockhash = latestBlockhash;
tx.add(instruction);
tx.feePayer = payer.publicKey;
tx.sign(payer, ...additionalSigners);
return await client.tryProcessTransaction(tx);
}
async function setupATA(
context: ProgramTestContext,
usdcMint: PublicKey,
owner: PublicKey,
amount: number
): Promise<PublicKey> {
const tokenAccData = Buffer.alloc(ACCOUNT_SIZE);
AccountLayout.encode(
{
mint: usdcMint,
owner,
amount: BigInt(amount),
delegateOption: 0,
delegate: PublicKey.default,
delegatedAmount: BigInt(0),
state: 1,
isNativeOption: 0,
isNative: BigInt(0),
closeAuthorityOption: 0,
closeAuthority: PublicKey.default,
},
tokenAccData,
);
const ata = getAssociatedTokenAddressSync(usdcMint, owner, true);
const ataAccountInfo = {
lamports: 1_000_000_000,
data: tokenAccData,
owner: TOKEN_PROGRAM_ID,
executable: false,
};
context.setAccount(ata, ataAccountInfo);
return ata;
}
Let's take a look at each of these functions:
createAndProcessTransaction
accepts some basic transaction information and aBanksClient
, which is a client for the ledger state, from the perspective of an arbitrary validator (it will be created when we initiate our tests with thestartAnchor
function). It then creates a new transaction, adds the provided instruction to it, signs it with the provided payer, and processes it using thetryProcessTransaction
function of theBanksClient
. This function is handy for testing as it returnsBanksTransactionResultWithMeta
, which contains the transaction logs, return data, compute units used, and (if applicable) an error.setupATA
accepts aProgramTestContext
, which is effectively an extension of theBanksClient
that includes some additional functionality, including thesetAccount
function, which allows us to set an account in the context. The function will encode the provided account data into a buffer, create an associated token account (ATA) for the provided owner and mint, and set the ATA's data to the encoded buffer. It will then return the ATA's public key.
Frame Our Test Environment
Now that we have our setup functions, we can start writing our tests. Below your support functions, describe the test suite:
describe("Bankrun Tests", () => {
const usdcMint = new PublicKey(USDC_MINT_ADDRESS);
let context: ProgramTestContext;
let client: BanksClient;
let payer: Keypair;
let provider: BankrunProvider;
let program: Program<BankrunTest>;
before(async () => {
const connection = new Connection(clusterApiUrl("mainnet-beta"));
const accountInfo = await connection.getAccountInfo(usdcMint);
const usdcAccount: AddedAccount = { address: usdcMint, info: accountInfo };
context = await startAnchor(PROJECT_DIRECTORY, [], [usdcAccount]);
client = context.banksClient;
payer = context.payer;
provider = new BankrunProvider(context);
setProvider(provider);
program = new Program<BankrunTest>(IDL, provider);
});
// TODO: Add Time Travel Tests Here
// TODO: Add Arbitrary Data Account Tests Here
});
Here we are defining a few variables that will be used globally throughout the tests:
usdcMint
: This is the public key of the USDC token mint.context
: This is an instance ofProgramTestContext
, which is a wrapper around theBanksClient
that includes additional functionality. This is initiated using thestartAnchor
function. Note that we are passing in theusdcAccount
. This will initiate our test environment with the USDC mint account initialized (we will write a test to verify this in a bit).client
: This is theBanksClient
that will be used to interact with the ledger state.payer
: This is the payer that will be used to sign transactions.provider
: This is an instance ofBankrunProvider
(an implementation of the AnchorProvider
) that will include additional context and functionality.program
: This is an instance of the anchorProgram<BankrunTest>
, which will be used just like any other anchor test suite.
Time Travel Tests
Let's create a few tests that help us verify the program's functionality. Since we have an instruction that will fail if the current slot is less than the MINIMUM_SLOT
, this test might could be cumbersome in a traditional testing environment. Bankrun allows us to "time travel" to different slots or epochs based on our needs. This can be achieved in one of two ways:
- using the
provider.context.warpToSlot
function (orwarpToEpoch
for epochs), or - by using the
context.setClock
function.
Inside your "Bankrun Tests" describe block*, after the "before" block, add the following "Time Travel Tests" describe block:
describe("Time Travel Tests", () => {
const testCases = [
{ desc: "(too early)", slot: MINIMUM_SLOT - 1, shouldSucceed: false },
{ desc: "(at or above threshold)", slot: MINIMUM_SLOT, shouldSucceed: true },
]
testCases.forEach(({ desc, slot, shouldSucceed }) => {
describe(`When slot is ${slot} ${desc}`, () => {
let txResult: BanksTransactionResultWithMeta;
let newData: Keypair;
let dataAccount: Keypair;
before(async () => {
provider.context.warpToSlot(BigInt(slot));
newData = Keypair.generate();
dataAccount = Keypair.generate();
const ix = await program.methods
.setData()
.accounts({
payer: payer.publicKey,
newData: newData.publicKey,
dataAccount: dataAccount.publicKey,
})
.signers([newData, dataAccount])
.instruction();
txResult = await createAndProcessTransaction(client, payer, ix, [newData, dataAccount]);
});
if (!shouldSucceed) {
it("transaction should fail", () => {
expect(txResult.result).to.exist;
});
it("should contain specific error details in log", () => {
const errorLog = txResult.meta.logMessages.find(log =>
log.includes('AnchorError') &&
log.includes('InvalidSlot') &&
log.includes('6000') &&
log.includes('Error Message: Invalid slot')
);
expect(errorLog).to.exist;
});
it("last log message should indicate failure", () => {
expect(txResult.meta.logMessages[txResult.meta.logMessages.length - 1]).to.include('failed');
});
} else {
it("transaction should succeed", () => {
expect(txResult.result).to.be.null;
});
it("last log message should indicate success", () => {
expect(txResult.meta.logMessages[txResult.meta.logMessages.length - 1]).to.include('success');
});
it("should contain expected log message", () => {
const expectedLog = "Set new data: " + newData.publicKey.toString();
const foundLog = txResult.meta.logMessages.some(log => log.includes(expectedLog));
expect(foundLog).to.be.true;
});
it("should set new data correctly", async () => {
const onChainData = await program.account.dataAccount.fetch(dataAccount.publicKey);
expect(onChainData.newData.toString()).to.equal(newData.publicKey.toString());
});
}
});
});
});
Let's break down the tests:
- First, we are defining an array of test cases. Each test case is an object that contains a description, the slot number, and a boolean value indicating whether the test should succeed or fail. In this case, we are testing one case where the current slot is less than the
MINIMUM_SLOT
constant (expected to fail), and another case where the current slot is greater than or equal to theMINIMUM_SLOT
constant (expected to succeed). For each test case, we create adescribe
block that describes the test case. Inside thedescribe
block, we create abefore
block that sets up the test environment. Before creating and sending the transaction, we use theprovider.context.warpToSlot
function to "time travel" to the specified slot. We then run a series of tests inside thedescribe
block depending on whether the instruction is expected to succeed or fail. For each test, we create anit
block that describes the test case. Inside theit
block, we check the transaction result and make assertions about the expected behavior. - The
createAndProcessTransaction
function only returns aresult
property if an error occured during the transaction, so we can use theexpect
function to check if the result is null or not. Additionally, thecreateAndProcessTransaction
function returns our program's log messages as ameta.logMessages
property. We have created a couple of helpers to find expected AnchorError logs or the expected success or failed log messages. - Finally, we use the Anchor
fetch
method to get thenewData
field from thedataAccount
account and assert that it matches the expected value.
We should be able to run the tests now. In your terminal enter:
anchor test
Your tests should pass, but you should notice that your terminal includes a lot of debugging information:
When slot is 100 (at or above threshold)
[2024-07-17T21:57:31.175139000Z DEBUG solana_runtime::message_processor::stable_log] Program 4uqt4ZDm7WhG3BfQASEADZNi5TUyE21zwXwbqUb1Qjmk invoke [1]
[2024-07-17T21:57:31.175203000Z DEBUG solana_runtime::message_processor::stable_log] Program log: Instruction: SetData
[2024-07-17T21:57:31.175246000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111111 invoke [2]
[2024-07-17T21:57:31.175254000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111111 success
[2024-07-17T21:57:31.175290000Z DEBUG solana_runtime::message_processor::stable_log] Program log: Current slot: 100
[2024-07-17T21:57:31.175372000Z DEBUG solana_runtime::message_processor::stable_log] Program log: Set new data: 5go3aphd2yJPqRpQEN8Pg4Asvp2xkpNLJxXc6HErm4a5
[2024-07-17T21:57:31.175383000Z DEBUG solana_runtime::message_processor::stable_log] Program 4uqt4ZDm7WhG3BfQASEADZNi5TUyE21zwXwbqUb1Qjmk consumed 18600 of 200000 compute units
[2024-07-17T21:57:31.175391000Z DEBUG solana_runtime::message_processor::stable_log] Program 4uqt4ZDm7WhG3BfQASEADZNi5TUyE21zwXwbqUb1Qjmk success
✔ transaction should succeed
✔ last log message should indicate success
✔ should contain expected log message
✔ should set new data correctly
This is another perk of using Bankrun, as it allows us to debug our tests more granularly. Though not a big deal since our tests are passing, this can be really useful in debugging programs quickly! You should be able to browse the logs and see exactly how and where your program logged the error or success messages (as we identified in our tests). Pretty cool, right?
Arbitrary Data Account Tests
Now let's look at some tests to help us explore BankRun's arbitrary data account functionality. We will test the check_spl_token
instruction, which will check if the token account has a balance greater than or equal to the TOKEN_MINIMUM_BALANCE
constant. If you recall, our Anchor program required that the token mint must be the USDC mint. Running this test would typically require using a fake USDC token, but Bankrun allows us to use arbitrary accounts, meaning we can write token account information to any account we want. In fact, we have already done this in our startAnchor
function--by initiating the usdcMint
account with account information that we pulled from the Solana mainnet. Let's (1) write a test to ensure the USDC mint is properly initialized and (2) write a set of tests to verify that the ATA with sufficient balance is initialized. Inside your "Bankrun Tests" describe block, after the "Time Travel Tests" block, add the following "Arbitrary Data Account Tests" describe block:
describe("Arbitrary Data Account Tests", () => {
const testCases = [
{ desc: "insufficient", amount: MINIMUM_USDC_BALANCE - 1_000_000, shouldSucceed: false },
{ desc: "sufficient", amount: MINIMUM_USDC_BALANCE, shouldSucceed: true },
];
describe("USDC mint initialization", () => {
let rawAccount: AccountInfoBytes;
before(async () => {
rawAccount = await client.getAccount(usdcMint);
});
it("should have initialized USDC mint", () => {
expect(rawAccount).to.exist;
});
it("should have correct decimals", () => {
const mintInfo = MintLayout.decode(rawAccount.data);
expect(mintInfo.decimals).to.equal(USDC_DECIMALS);
});
});
testCases.forEach(({ desc, amount, shouldSucceed }) => {
describe(`ATA with ${desc} USDC balance`, () => {
let ata: PublicKey;
let txResult: BanksTransactionResultWithMeta;
before(async () => {
let owner = Keypair.generate();
ata = await setupATA(context, usdcMint, owner.publicKey, amount);
const ix = await program.methods
.checkSplToken()
.accounts({
tokenAccount: ata,
})
.instruction();
txResult = await createAndProcessTransaction(client, payer, ix);
});
it("should have initialized USDC ATA", async () => {
const rawAccount = await client.getAccount(ata);
expect(rawAccount).to.exist;
});
it("should have correct balance in ATA", async () => {
const accountInfo = await client.getAccount(ata);
const tokenAccountInfo = AccountLayout.decode(accountInfo.data);
expect(tokenAccountInfo.amount).to.equal(BigInt(amount));
});
if (shouldSucceed) {
it("should process the transaction successfully", () => {
expect(txResult.result).to.be.null;
});
it("last log message should indicate success", () => {
expect(txResult.meta.logMessages[txResult.meta.logMessages.length - 1]).to.include('success');
});
} else {
it("should fail to process the transaction", () => {
expect(txResult.result).to.exist;
});
it("should contain specific error details in log", () => {
const errorLog = txResult.meta.logMessages.find(log =>
log.includes('AnchorError') &&
log.includes('InsufficientTokenBalance') &&
log.includes('6001') &&
log.includes('Error Message: Insufficient token balance.')
);
expect(errorLog).to.exist;
});
it("last log message should indicate failure", () => {
expect(txResult.meta.logMessages[txResult.meta.logMessages.length - 1]).to.include('failed');
});
}
});
});
});
Let's break down the tests:
- First, like before, we define an array of test cases. Each test case is an object that contains a description, the amount of USDC balance, and a boolean value indicating whether the test should succeed or fail. In this case, we are testing one case where the ATA has an insufficient balance (expected to fail), and another case where the ATA has sufficient balance (expected to succeed).
- Next, we are creating a test for the USDC mint initialization. We are using the
getAccount
method of theBanksClient
to get the raw account information for the USDC mint. We then use theexpect
function to check if the account exists and if the decimals match the expected value. For each test case, we create adescribe
block that describes the test case. Inside thedescribe
block, we create abefore
block that sets up the test environment. Before creating and sending the transaction, we use thesetupATA
function to create an ATA with the specified balance. We then use thecreateAndProcessTransaction
function to send the transaction to the ledger state. - We then run a series of tests inside the
describe
block depending on whether the instruction is expected to succeed or fail. For each test, we are creating anit
block that describes the test case. Inside theit
block, we are checking the result of the transaction and making assertions about the expected behavior. These tests structurally mirror the tests we wrote before, leveraging theresults
andmeta.logMessages
properties of theBanksTransactionResultWithMeta
object.
Run the Tests
In your terminal, enter:
anchor test
Your tests should pass:
18 passing (2s)
✨ Done in 3.02s.
Wow! That's fast! Despite sending several transactions to the cluster, our tests run in a fraction of a second. This is a huge advantage of using Bankrun, as it allows us to run our tests in a much more efficient manner! But what about those logs? Don't worry--if you do not want to see them, you can remove them easily.
Open up Anchor.toml
and update your [scripts]
section to look like this:
[scripts]
test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
test_debug = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
Now, when you run anchor test
, you should see no logs:
Bankrun Tests
Time Travel Tests
When slot is 99 (too early)
✔ transaction should fail
✔ should contain specific error details in log
✔ last log message should indicate failure
When slot is 100 (at or above threshold)
✔ transaction should succeed
✔ last log message should indicate success
✔ should contain expected log message
✔ should set new data correctly
Arbitrary Data Account Tests
USDC mint initialization
✔ should have initialized USDC mint
✔ should have correct decimals
ATA with insufficient USDC balance
✔ should have initialized USDC ATA
✔ should have correct balance in ATA
✔ should fail to process the transaction
✔ should contain specific error details in log
✔ last log message should indicate failure
ATA with sufficient USDC balance
✔ should have initialized USDC ATA
✔ should have correct balance in ATA
✔ should process the transaction successfully
✔ last log message should indicate success
18 passing (442ms)
✨ Done in 1.19s.
This is great! But we also added a test_debug
script that will display the logs if we need to debug something. Let's rerun the tests, but this time we will use the test_debug
script:
anchor run test_debug
And there you have it! Your logs are displayed. This is a great way to debug your tests and make sure that they are working as expected.
Wrap Up
Great work! You now have some additional tools in your tool belt to accelerate your Solana program testing and development.
Resources
- Solana Bankrun Docs
- Solana Bankrun GitHub
- Anchor Bankrun GitHub
- Solana Bankrun Tutorial
- Solana Bankrun Explaier Thread (x.com)
Let's Connect!
We'd love to hear what you are building and testing. Send us your experience, questions, or feedback via Twitter or Discord.
We ❤️ Feedback!
Let us know if you have any feedback or requests for new topics. We'd love to hear from you.